Use local Images when possible (#1580)

* Fix advance settings android layout

* include react-native-image-gallery dependency

* ImageCacheManager

* Add support for isGif, isDocument, isVideo and purge to file utils

* Remove file fetch cache

* include progressive image component

* Refactor image previewer

* Add image preview support for open graph and embed images

* Cache user profile picture

* Fix post additional content images for android

* Preview markdown inline images

* Improve progressive image

* feedback review
This commit is contained in:
Elias Nahum 2018-04-13 12:23:15 -03:00 committed by GitHub
parent c0fa74203c
commit 006c5a08d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1630 additions and 1583 deletions

View file

@ -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,
};
}

View file

@ -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 = (
<TouchableOpacity onPress={this.handlePreviewPress}>
<FileAttachmentImage
addFileToFetchCache={this.props.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
onCaptureRef={this.handleCaptureRef}
onCapturePreviewRef={this.handleCapturePreviewRef}
theme={theme}
/>
</TouchableOpacity>
);
} else if (SUPPORTED_DOCS_FORMAT.includes(mime)) {
} else if (isDocument(file)) {
fileAttachmentComponent = (
<FileAttachmentDocument
file={file}
theme={theme}
navigator={navigator}
theme={theme}
/>
);
} else {
@ -96,6 +116,8 @@ export default class FileAttachment extends PureComponent {
<TouchableOpacity onPress={this.handlePreviewPress}>
<FileAttachmentIcon
file={file}
onCaptureRef={this.handleCaptureRef}
onCapturePreviewRef={this.handleCapturePreviewRef}
theme={theme}
/>
</TouchableOpacity>

View file

@ -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});
});
}

View file

@ -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 (
<View style={[styles.fileIconWrapper, {height: wrapperHeight, width: wrapperWidth}]}>
<Image
<View
ref={this.handleCaptureRef}
style={[styles.fileIconWrapper, {height: wrapperHeight, width: wrapperWidth}]}
>
<ProgressiveImage
ref={this.handleCapturePreviewRef}
style={[styles.icon, {height: iconHeight, width: iconWidth}]}
source={source}
defaultSource={genericIcon}
defaultSource={source}
/>
</View>
);

View file

@ -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 (
<View style={[style.fileImageWrapper, {backgroundColor: wrapperBackgroundColor, height: wrapperHeight, width: wrapperWidth, overflow: 'hidden'}]}>
<AnimatedView style={{height: imageHeight, width: imageWidth, backgroundColor: wrapperBackgroundColor, opacity}}>
<Image
style={imageStyle}
source={source}
resizeMode={resizeMode}
resizeMethod={resizeMethod}
{...imageComponentLoaders}
/>
</AnimatedView>
{(!isInFetchCache && !file.failed && (file.loading || this.state.requesting)) &&
<View style={[style.loaderContainer, {backgroundColor: loadingBackgroundColor}]}>
<ActivityIndicator size='small'/>
</View>
}
<View
ref={this.handleCaptureRef}
style={[style.fileImageWrapper, {height: wrapperHeight, width: wrapperWidth, overflow: 'hidden'}]}
>
<ProgressiveImage
ref={this.handleCapturePreviewRef}
style={imageStyle}
filename={file.name}
resizeMode={resizeMode}
resizeMethod={resizeMethod}
{...imageProps}
/>
</View>
);
}

View file

@ -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) => (
<FileAttachment
key={id}
addFileToFetchCache={this.props.actions.addFileToFetchCache}
deviceWidth={deviceWidth}
fetchCache={this.props.fetchCache}
file={{loading: true}}
index={idx}
theme={this.props.theme}
/>
));
} else {
fileAttachments = files.map((file) => (
<TouchableOpacity
key={file.id}
onLongPress={this.props.onLongPress}
onPressIn={this.handlePressIn}
onPressOut={this.handlePressOut}
>
<FileAttachment
deviceWidth={deviceWidth}
navigator={navigator}
addFileToFetchCache={this.props.actions.addFileToFetchCache}
fetchCache={this.props.fetchCache}
file={file}
onInfoPress={this.handleInfoPress}
onPreviewPress={this.handlePreviewPress}
theme={this.props.theme}
/>
</TouchableOpacity>
));
}
return files.map((file, idx) => (
<TouchableOpacity
key={file.id}
onLongPress={this.props.onLongPress}
onPressIn={this.handlePressIn}
onPressOut={this.handlePressOut}
>
<FileAttachment
deviceWidth={deviceWidth}
file={file}
index={idx}
navigator={navigator}
onCaptureRef={this.handleCaptureRef}
onCapturePreviewRef={this.handleCapturePreviewRef}
onInfoPress={this.handleInfoPress}
onPreviewPress={this.handlePreviewPress}
theme={this.props.theme}
/>
</TouchableOpacity>
));
};
render() {
const {fileIds, isFailed} = this.props;
return (
<View style={styles.flex}>
<ScrollView
@ -130,7 +244,7 @@ export default class FileAttachmentList extends Component {
scrollEnabled={fileIds.length > 1}
style={[styles.flex, (isFailed && styles.failed)]}
>
{fileAttachments}
{this.renderItems()}
</ScrollView>
</View>
);

View file

@ -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),
};

View file

@ -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 = (
<FileAttachmentImage
addFileToFetchCache={addFileToFetchCache}
fetchCache={fetchCache}
file={file}
imageHeight={100}
imageWidth={100}

View file

@ -7,13 +7,11 @@ import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {handleRemoveFile, retryFileUpload, uploadComplete, uploadFailed} from 'app/actions/views/file_upload';
import {addFileToFetchCache} from 'app/actions/views/file_preview';
import FileUploadItem from './file_upload_item';
function mapStateToProps(state) {
return {
fetchCache: state.views.fetchCache,
theme: getTheme(state),
};
}
@ -21,7 +19,6 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
addFileToFetchCache,
handleRemoveFile,
retryFileUpload,
uploadComplete,

View file

@ -151,6 +151,7 @@ export default class Markdown extends PureComponent {
return (
<MarkdownImage
linkDestination={linkDestination}
navigator={this.props.navigator}
onLongPress={this.props.onLongPress}
source={src}
errorTextStyle={[this.computeTextStyle(this.props.baseTextStyle, context), this.props.textStyles.error]}

View file

@ -12,13 +12,15 @@ import {
StyleSheet,
Text,
TouchableHighlight,
TouchableWithoutFeedback,
View,
} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import ProgressiveImage from 'app/components/progressive_image';
import CustomPropTypes from 'app/constants/custom_prop_types';
import mattermostManaged from 'app/mattermost_managed';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {normalizeProtocol} from 'app/utils/url';
const MAX_IMAGE_HEIGHT = 150;
@ -30,6 +32,7 @@ export default class MarkdownImage extends React.Component {
static propTypes = {
children: PropTypes.node,
linkDestination: PropTypes.string,
navigator: PropTypes.object.isRequired,
onLongPress: PropTypes.func,
serverURL: PropTypes.string.isRequired,
source: PropTypes.string.isRequired,
@ -45,16 +48,17 @@ export default class MarkdownImage extends React.Component {
this.state = {
width: 0,
height: 0,
height: MAX_IMAGE_HEIGHT,
maxWidth: Math.MAX_INT,
failed: false,
uri: null,
};
this.mounted = false;
}
componentWillMount() {
this.loadImageSize(this.getSource());
ImageCacheManager.cache(null, this.getSource(), this.setImageUrl);
}
componentDidMount() {
@ -70,7 +74,7 @@ export default class MarkdownImage extends React.Component {
});
// getSource also depends on serverURL, but that shouldn't change while this is mounted
this.loadImageSize(this.getSource(nextProps));
ImageCacheManager.cache(null, this.getSource(nextProps), this.setImageUrl);
}
}
@ -78,6 +82,26 @@ export default class MarkdownImage extends React.Component {
this.mounted = false;
}
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} : {};
};
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 = (
<Image
source={{uri: this.getSource()}}
resizeMode='contain'
style={[{width, height}, style.image]}
/>
<TouchableWithoutFeedback
onLongPress={this.handleLinkLongPress}
onPress={this.handlePreviewImage}
style={{width, height}}
>
<ProgressiveImage
ref='image'
defaultSource={source}
resizeMode='contain'
style={[{width, height}, style.image]}
/>
</TouchableWithoutFeedback>
);
}
} else if (this.state.failed) {
@ -224,7 +325,8 @@ export default class MarkdownImage extends React.Component {
return (
<View
style={style.container}
ref='item'
style={[style.container, {height: Math.min(this.state.height, MAX_IMAGE_HEIGHT)}]}
onLayout={this.handleLayout}
>
{image}

View file

@ -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}
</Text>
</View>
{imageLoaded &&
<Image
style={[style.image, {width, height}]}
source={{uri: imageUrl}}
resizeMode='cover'
/>
{hasImage &&
<View ref='item'>
<TouchableWithoutFeedback
onPress={this.handlePreviewImage}
style={{width, height}}
>
<ProgressiveImage
ref='image'
style={[style.image, {width, height}]}
imageUri={imageUrl}
resizeMode='cover'
/>
</TouchableWithoutFeedback>
</View>
}
</View>
);

View file

@ -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 {
<PostAttachmentOpenGraph
isReplyPost={isReplyPost}
link={link}
navigator={navigator}
openGraphData={openGraphData}
theme={theme}
/>
@ -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 (
<TouchableWithoutFeedback
style={[styles.imageContainer, {height: imgHeight}]}
{...this.responder}
onPress={this.playYouTubeVideo}
>
<ProgressiveImage
isBackgroundImage={true}
imageUri={imgUrl}
style={[styles.image, {width, height: imgHeight}]}
thumbnailUri={thumbUrl}
resizeMode='cover'
onError={this.handleLinkLoadError}
>
<TouchableWithoutFeedback onPress={this.playYouTubeVideo}>
<Image
source={youtubePlayIcon}
onPress={this.playYouTubeVideo}
/>
</TouchableWithoutFeedback>
</ProgressiveImage>
</TouchableWithoutFeedback>
);
}
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 (
<TouchableWithoutFeedback
onPress={this.handlePreviewImage}
style={[styles.imageContainer, {height: imgHeight}]}
{...this.responder}
>
<View ref='item'>
<ProgressiveImage
ref='image'
style={[styles.image, {width, height: imgHeight}]}
defaultSource={{uri}}
resizeMode='cover'
onError={this.handleLinkLoadError}
/>
</View>
</TouchableWithoutFeedback>
);
}
}
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 (
<TouchableWithoutFeedback
style={[styles.imageContainer, {height: imgHeight}]}
{...this.responder}
onPress={this.playYouTubeVideo}
>
<ImageBackground
style={[styles.image, {width, height: imgHeight}]}
source={{uri: imgUrl}}
resizeMode={'cover'}
onError={this.handleLinkLoadError}
>
<TouchableWithoutFeedback onPress={this.playYouTubeVideo}>
<Image
source={youtubePlayIcon}
onPress={this.playYouTubeVideo}
/>
</TouchableWithoutFeedback>
</ImageBackground>
</TouchableWithoutFeedback>
);
}
handleLinkLoadError = () => {
this.setState({linkLoadError: true});
};
if (isImage) {
return (
<View style={[styles.imageContainer, {height: imgHeight}]}>
<Image
style={[styles.image, {width, height: imgHeight}]}
source={{uri: link}}
resizeMode={'cover'}
onError={this.handleLinkLoadError}
/>
</View>
);
}
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;

View file

@ -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 (
<View style={{width: this.props.size + STATUS_BUFFER, height: this.props.size + STATUS_BUFFER}}>
<Image
key={pictureUrl}
style={{width: this.props.size, height: this.props.size, borderRadius: this.props.size / 2}}
source={{uri: pictureUrl}}
source={source}
defaultSource={placeholder}
/>
{(showStatus || edit) &&

View file

@ -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 (
<View {...{style}}>
{(hasDefaultSource && !hasPreview && !hasURI) &&
<DefaultComponent
{...otherProps}
source={defaultSource}
style={computedStyle}
>
{this.props.children}
</DefaultComponent>
}
{hasPreview && !isImageReady &&
<ImageComponent
{...otherProps}
source={{uri: thumb}}
style={computedStyle}
blurRadius={5}
>
{this.props.children}
</ImageComponent>
}
{isImageReady &&
<ImageComponent
{...otherProps}
source={{uri}}
style={computedStyle}
>
{this.props.children}
</ImageComponent>
}
{hasPreview &&
<Animated.View style={[computedStyle, {backgroundColor: 'black', opacity}]}/>
}
</View>
);
}
}

View file

@ -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 (
<View style={styles.container}>
<View style={styles.controlsRow}/>
@ -160,8 +166,9 @@ export default class VideoControls extends PureComponent {
</View>
<Slider
style={styles.progressSlider}
onSlidingComplete={this.seekVideo}
onSlidingStart={this.seekStart}
onSlidingComplete={this.seekVideoEnd}
onValueChange={this.seekVideo}
onSlidingStart={this.seekVideoStart}
maximumValue={Math.floor(this.props.duration)}
value={Math.floor(this.props.progress)}
trackStyle={styles.track}
@ -180,19 +187,30 @@ export default class VideoControls extends PureComponent {
);
}
seekStart = () => {
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 (
<TouchableWithoutFeedback onPress={this.toggleControls}>
<Animated.View style={[styles.container, {opacity: this.state.opacity}]}>
{this.renderControls()}
</Animated.View>
</TouchableWithoutFeedback>
<Animated.View style={[styles.container, {opacity: this.state.opacity}]}>
{this.renderControls()}
</Animated.View>
);
}
}
@ -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,

View file

@ -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`,
};

View file

@ -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,

View file

@ -263,7 +263,6 @@ const state = {
channel: {
drafts: {},
},
fetchCache: {},
i18n: {
locale: '',
},

View file

@ -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;
}
}

View file

@ -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,

View file

@ -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();

View file

@ -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

View file

@ -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 (
<AnimatedView
key={file.id}
style={[style.pageWrapper, {height: this.props.deviceHeight, width: this.props.deviceWidth, opacity: index === this.state.currentFile ? 1 : this.state.limitOpacity}]}
>
{component}
</AnimatedView>
);
});
};
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 (
<View style={[style.flex, style.center]}>
<FileAttachmentDocument
ref={(ref) => {
this.documents[this.state.index] = ref;
}}
file={file}
theme={theme}
navigator={navigator}
iconHeight={120}
iconWidth={120}
wrapperHeight={200}
wrapperWidth={200}
/>
</View>
);
};
handleVideoSeek = (seeking) => {
this.setState({
isZooming: !seeking,
});
renderAttachmentIcon = (file) => {
return (
<View style={[style.flex, style.center]}>
<FileAttachmentIcon
file={file}
theme={this.props.theme}
iconHeight={120}
iconWidth={120}
wrapperHeight={200}
wrapperWidth={200}
/>
</View>
);
};
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 = (
<Icon
name='md-more'
size={32}
color='#fff'
/>
);
} else if (file.source || isVideo(file.data)) {
icon = (
<Icon
name='ios-download-outline'
size={26}
color='#fff'
/>
);
}
}
return (
<TouchableOpacity
onPress={action}
style={style.headerIcon}
>
{icon}
</TouchableOpacity>
);
}
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 (
<Downloader
ref='downloader'
show={this.state.showDownloader}
file={file}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
onDownloadCancel={this.hideDownloader}
onDownloadStart={this.hideDownloader}
onDownloadSuccess={this.hideDownloader}
/>
);
}
renderFooter() {
const {files} = this.props;
const {index} = this.state;
const footer = this.getHeaderFooterStyle();
return (
<Animated.View style={[{bottom: footer.start, opacity: footer.opacity}, style.footerContainer]}>
<LinearGradient
style={style.footer}
start={{x: 0.0, y: 0.0}}
end={{x: 0.0, y: 0.9}}
colors={['transparent', '#000000']}
pointerEvents='none'
>
<Text style={style.filename}>
{(files[index] && files[index].caption) || ''}
</Text>
</LinearGradient>
</Animated.View>
);
}
renderGallery() {
return (
<Gallery
errorComponent={this.renderOtherItems}
images={this.props.files}
initialPage={this.state.index}
onLayout={this.handleGalleryLayout}
onPageSelected={this.handleChangeImage}
onSingleTapConfirmed={this.handleTapped}
onSwipedVertical={this.handleSwipedVertical}
pageMargin={2}
style={style.flex}
/>
);
}
renderHeader() {
const {files} = this.props;
const {index} = this.state;
const header = this.getHeaderFooterStyle();
return (
<AnimatedView style={[style.headerContainer, {top: header.start, opacity: header.opacity}]}>
<View style={style.header}>
<View style={style.headerControls}>
<TouchableOpacity
onPress={this.close}
style={style.headerIcon}
>
<Icon
name='md-close'
size={26}
color='#fff'
/>
</TouchableOpacity>
<Text style={style.title}>
{`${index + 1}/${files.length}`}
</Text>
{this.renderDownloadButton()}
</View>
</View>
</AnimatedView>
);
}
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 <View/>;
};
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 (
<ScrollView scrollEnabled={false}>
<Animated.View style={[style.center, style.flex, containerStyle]}>
<ProgressiveImage
{...previewProps}
style={[StyleSheet.absoluteFill, style.fullWidth]}
resizeMode='contain'
/>
</Animated.View>
</ScrollView>
);
};
renderVideoPreview = (file) => {
const {deviceHeight, deviceWidth, theme} = this.props;
return (
<VideoPreview
file={file}
onFullScreen={this.setHeaderAndFooterVisible}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
theme={theme}
/>
);
};
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 (
<FileAttachmentDocument
file={file}
theme={theme}
iconHeight={120}
iconWidth={120}
wrapperHeight={200}
wrapperWidth={200}
/>
);
};
renderAttachmentIcon = (file) => {
return (
<TouchableOpacity
activeOpacity={1}
onPress={this.handleImageTap}
>
<FileAttachmentIcon
file={file}
theme={this.props.theme}
iconHeight={120}
iconWidth={120}
wrapperHeight={200}
wrapperWidth={200}
/>
</TouchableOpacity>
);
};
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 = (
<Icon
name='md-more'
size={32}
color='#fff'
/>
);
} else if (file.has_preview_image || SUPPORTED_VIDEO_FORMAT.includes(file.mime_type)) {
action = this.showDownloadOptions;
icon = (
<Icon
name='ios-download-outline'
size={26}
color='#fff'
/>
);
}
}
return (
<TouchableOpacity
onPress={action}
style={style.headerIcon}
>
{icon}
</TouchableOpacity>
);
}
return null;
};
renderPreviewer = (file, index) => {
const maxImageHeight = this.props.deviceHeight - STATUSBAR_HEIGHT;
return (
<Previewer
ref={(c) => {
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 (
<VideoPreview
file={file}
onFullScreen={this.handleImageTap}
onSeeking={this.handleVideoSeek}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
theme={theme}
/>
);
};
renderSwiper = () => {
return (
<Swiper
ref='swiper'
initialPage={this.initialPage}
onIndexChanged={this.handleIndexChanged}
width={this.props.deviceWidth}
activeDotColor={this.props.theme.sidebarBg}
dotColor={this.props.theme.sidebarText}
scrollEnabled={!this.state.isZooming}
showsPagination={false}
onScrollBegin={this.handleScroll}
>
{this.getPreviews()}
</Swiper>
);
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 (
<SafeAreaView
backgroundColor='#000'
navBarBackgroundColor='#000'
footerColor='#000'
excludeHeader={true}
>
<View
style={[style.wrapper]}
onLayout={this.handleLayout}
>
<AnimatedView
style={[this.state.drag.getLayout(), {opacity: this.state.wrapperViewOpacity, flex: 1}]}
{...this.mainViewPanResponder.panHandlers}
>
{this.renderSwiper()}
<AnimatedView style={[style.headerContainer, {width: this.props.deviceWidth, opacity: this.state.footerOpacity}]}>
<View style={style.header}>
<View style={style.headerControls}>
<TouchableOpacity
onPress={this.handleClose}
style={style.headerIcon}
>
<Icon
name='md-close'
size={26}
color='#fff'
/>
</TouchableOpacity>
<Text style={style.title}>
{`${currentFile + 1}/${files.length}`}
</Text>
{this.renderDownloadButton()}
</View>
</View>
</AnimatedView>
<AnimatedView style={[style.footerContainer, {width: this.props.deviceWidth, opacity: this.state.footerOpacity}]}>
<LinearGradient
style={style.footer}
start={{x: 0.0, y: 0.0}}
end={{x: 0.0, y: 0.9}}
colors={['transparent', '#000000']}
pointerEvents='none'
>
<Text style={style.filename}>
{fileName}
</Text>
</LinearGradient>
</AnimatedView>
</AnimatedView>
<Downloader
ref='downloader'
show={this.state.showDownloader}
file={file}
deviceHeight={this.props.deviceHeight}
deviceWidth={this.props.deviceWidth}
onDownloadCancel={this.hideDownloader}
onDownloadStart={this.hideDownloader}
onDownloadSuccess={this.hideDownloader}
/>
</View>
</SafeAreaView>
<AnimatedSafeAreaView style={[style.container, opacity]}>
<AnimatedView style={style.container}>
{this.renderSelectedItem()}
{this.state.gallery && this.renderGallery()}
{this.renderHeader()}
{this.renderFooter()}
</AnimatedView>
{this.renderDownloader()}
</AnimatedSafeAreaView>
);
}
}
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',

View file

@ -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 (
<View
{...this.panResponder.panHandlers}
onResponderRelease={() => {
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,
}}
>
<AnimatedImage
{...otherProps}
style={{height, width}}
/>
</View>
);
}
}

View file

@ -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 (
<ScrollView
ref={this.attachScrollView}
alwaysBounceHorizontal={false}
alwaysBounceVertical={false}
bounces={false}
contentContainerStyle={style.content}
centerContent={true}
maximumZoomScale={maximumZoomScale}
minimumZoomScale={minimumZoomScale}
onScroll={this.handleScroll}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={showsHorizontalScrollIndicator}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
>
<AnimatedImage {...otherProps}/>
</ScrollView>
);
}
}
const style = StyleSheet.create({
content: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
});

View file

@ -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);

View file

@ -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 (
<View
{...this.panResponder.panHandlers}
onResponderRelease={this.handleResponderRelease}
style={[style.fileImageWrapper, {height: '100%', width: '100%'}]}
>
<AnimatedView style={[containerStyle, {opacity}]}>
<ImageView
ref={this.attachImageView}
source={source}
minimumZoomScale={1}
maximumZoomScale={3}
onZoom={this.handleZoom}
resizeMode='contain'
imageHeight={imageHeight}
imageWidth={imageWidth}
style={{height: this.state.imageHeight, width: this.state.imageWidth}}
wrapperHeight={wrapperHeight}
wrapperWidth={wrapperWidth}
{...imageComponentLoaders}
/>
</AnimatedView>
{(!isInFetchCache && this.state.requesting) &&
<View style={[style.loaderContainer, {backgroundColor: 'white'}]}>
<ActivityIndicator size='small'/>
</View>
}
</View>
);
}
}
const style = StyleSheet.create({
fileImageWrapper: {
alignItems: 'center',
justifyContent: 'center',
},
loaderContainer: {
position: 'absolute',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
},
});

View file

@ -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 (
<View style={[styles.container, {height: deviceHeight, width: deviceWidth}]}>
<Video
ref='videoPlayer'
style={[styles.mediaPlayer, {width: deviceWidth}]}
resizeMode='contain'
source={{uri: path}}
volume={1.0}
paused={paused}
onEnd={this.onEnd}
onLoad={this.onLoad}
onLoadStart={this.onLoadStart}
onProgress={this.onProgress}
onError={this.onError}
/>
<View style={StyleSheet.absoluteFill}>
<TouchableOpacity
style={StyleSheet.absoluteFill}
activeOpacity={1}
onPress={() => this.refs.controls.fadeInControls()}
>
<Video
ref='videoPlayer'
style={[StyleSheet.absoluteFill, {position: 'absolute'}]}
resizeMode='contain'
source={{uri: path}}
volume={1.0}
paused={paused}
onEnd={this.onEnd}
onLoad={this.onLoad}
onLoadStart={this.onLoadStart}
onProgress={this.onProgress}
onError={this.onError}
/>
</TouchableOpacity>
<VideoControls
ref='controls'
mainColor={theme.linkColor}
@ -223,17 +232,3 @@ export default class VideoPreview extends PureComponent {
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
mediaPlayer: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
backgroundColor: 'black',
},
});

View file

@ -215,7 +215,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
color: theme.centerChannelColor,
flex: 1,
fontSize: 14,
lineHeight: 43,
...Platform.select({
android: {
lineHeight: 68,
},
ios: {
lineHeight: 43,
},
}),
},
};
});

View file

@ -46,6 +46,7 @@ export default makeStyleSheetFromTheme((theme) => {
height: 1,
},
arrowContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingRight: 15,
},

View file

@ -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);
};

View file

@ -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);
});
};

View file

@ -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",

View file

@ -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"