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:
parent
c0fa74203c
commit
006c5a08d7
35 changed files with 1630 additions and 1583 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
163
app/components/progressive_image.js
Normal file
163
app/components/progressive_image.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -263,7 +263,6 @@ const state = {
|
|||
channel: {
|
||||
drafts: {},
|
||||
},
|
||||
fetchCache: {},
|
||||
i18n: {
|
||||
locale: '',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export default makeStyleSheetFromTheme((theme) => {
|
|||
height: 1,
|
||||
},
|
||||
arrowContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingRight: 15,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
81
app/utils/image_cache_manager.js
Normal file
81
app/utils/image_cache_manager.js
Normal 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);
|
||||
});
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
23
yarn.lock
23
yarn.lock
|
|
@ -5598,7 +5598,7 @@ promise@^7.1.1:
|
|||
dependencies:
|
||||
asap "~2.0.3"
|
||||
|
||||
prop-types@15.6.1:
|
||||
prop-types@15.6.1, prop-types@^15.6.0:
|
||||
version "15.6.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.1.tgz#36644453564255ddda391191fb3a125cbdf654ca"
|
||||
dependencies:
|
||||
|
|
@ -5613,7 +5613,7 @@ prop-types@^15.5.0, prop-types@^15.5.8:
|
|||
fbjs "^0.8.9"
|
||||
loose-envify "^1.3.1"
|
||||
|
||||
prop-types@^15.5.10, prop-types@^15.5.6, prop-types@^15.6.0:
|
||||
prop-types@^15.5.10, prop-types@^15.5.6:
|
||||
version "15.6.0"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856"
|
||||
dependencies:
|
||||
|
|
@ -5852,6 +5852,13 @@ react-lifecycles-compat@^1.0.2:
|
|||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-1.0.2.tgz#551d8b1d156346e5fcf30ffac9b32ce3f78b8850"
|
||||
|
||||
react-mixin@^3.0.5:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-mixin/-/react-mixin-3.1.1.tgz#68749756bfe32699e561372a4aeecb926db72b7f"
|
||||
dependencies:
|
||||
object-assign "^4.0.1"
|
||||
smart-mixin "^2.0.0"
|
||||
|
||||
react-native-animatable@1.2.4:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react-native-animatable/-/react-native-animatable-1.2.4.tgz#b5fb7657e8f6edadbc26697057a327fb920b3039"
|
||||
|
|
@ -5929,6 +5936,14 @@ react-native-fetch-blob@enahum/react-native-fetch-blob.git:
|
|||
base-64 "0.1.0"
|
||||
glob "7.0.6"
|
||||
|
||||
react-native-image-gallery@enahum/react-native-image-gallery:
|
||||
version "2.1.5"
|
||||
resolved "https://codeload.github.com/enahum/react-native-image-gallery/tar.gz/7dd88b037cbb7ee36866585357b33bddb20bd12e"
|
||||
dependencies:
|
||||
prop-types "^15.6.0"
|
||||
react-mixin "^3.0.5"
|
||||
react-timer-mixin "^0.13.3"
|
||||
|
||||
react-native-image-picker@0.26.7:
|
||||
version "0.26.7"
|
||||
resolved "https://registry.yarnpkg.com/react-native-image-picker/-/react-native-image-picker-0.26.7.tgz#ad2ee957f7f6cc01396893ea03d84cb2adb2e376"
|
||||
|
|
@ -7021,6 +7036,10 @@ slide@^1.1.5:
|
|||
version "1.1.6"
|
||||
resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
|
||||
|
||||
smart-mixin@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/smart-mixin/-/smart-mixin-2.0.0.tgz#a34a1055e32a75b30d2b4e3ca323dc99cb53f437"
|
||||
|
||||
snapdragon-node@^2.0.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
|
||||
|
|
|
|||
Loading…
Reference in a new issue