Fix cropped images (#1936)

* Fix cropped images

* Unit tests
This commit is contained in:
Elias Nahum 2018-07-20 13:34:46 -04:00 committed by GitHub
parent c714072b9b
commit b47c7db4dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 444 additions and 374 deletions

View file

@ -24,7 +24,6 @@ export default class FileAttachment extends PureComponent {
file: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
onCaptureRef: PropTypes.func,
onCapturePreviewRef: PropTypes.func,
onInfoPress: PropTypes.func,
onPreviewPress: PropTypes.func,
theme: PropTypes.object.isRequired,
@ -44,14 +43,6 @@ export default class FileAttachment extends PureComponent {
}
};
handleCapturePreviewRef = (ref) => {
const {onCapturePreviewRef, index} = this.props;
if (onCapturePreviewRef) {
onCapturePreviewRef(ref, index);
}
};
handlePreviewPress = () => {
this.props.onPreviewPress(this.props.index);
};
@ -105,7 +96,6 @@ export default class FileAttachment extends PureComponent {
<FileAttachmentImage
file={data || {}}
onCaptureRef={this.handleCaptureRef}
onCapturePreviewRef={this.handleCapturePreviewRef}
theme={theme}
/>
</TouchableOpacity>
@ -124,7 +114,6 @@ export default class FileAttachment extends PureComponent {
<FileAttachmentIcon
file={data}
onCaptureRef={this.handleCaptureRef}
onCapturePreviewRef={this.handleCapturePreviewRef}
theme={theme}
/>
</TouchableOpacity>

View file

@ -42,7 +42,6 @@ export default class FileAttachmentIcon extends PureComponent {
iconHeight: PropTypes.number,
iconWidth: PropTypes.number,
onCaptureRef: PropTypes.func,
onCapturePreviewRef: PropTypes.func,
wrapperHeight: PropTypes.number,
wrapperWidth: PropTypes.number,
};
@ -67,14 +66,6 @@ export default class FileAttachmentIcon extends PureComponent {
}
};
handleCapturePreviewRef = (ref) => {
const {onCapturePreviewRef} = this.props;
if (onCapturePreviewRef) {
onCapturePreviewRef(ref);
}
};
render() {
const {file, iconHeight, iconWidth, wrapperHeight, wrapperWidth} = this.props;
const source = this.getFileIconPath(file);
@ -85,7 +76,6 @@ export default class FileAttachmentIcon extends PureComponent {
style={[styles.fileIconWrapper, {height: wrapperHeight, width: wrapperWidth}]}
>
<ProgressiveImage
ref={this.handleCapturePreviewRef}
style={[styles.icon, {height: iconHeight, width: iconWidth}]}
defaultSource={source}
/>

View file

@ -35,7 +35,6 @@ export default class FileAttachmentImage extends PureComponent {
]),
imageWidth: PropTypes.number,
onCaptureRef: PropTypes.func,
onCapturePreviewRef: PropTypes.func,
resizeMode: PropTypes.string,
resizeMethod: PropTypes.string,
wrapperHeight: PropTypes.number,
@ -92,14 +91,6 @@ export default class FileAttachmentImage extends PureComponent {
}
};
handleCapturePreviewRef = (ref) => {
const {onCapturePreviewRef} = this.props;
if (onCapturePreviewRef) {
onCapturePreviewRef(ref);
}
};
render() {
const {
file,
@ -135,7 +126,6 @@ export default class FileAttachmentImage extends PureComponent {
style={[style.fileImageWrapper, {height: wrapperHeight, width: wrapperWidth, overflow: 'hidden'}]}
>
<ProgressiveImage
ref={this.handleCapturePreviewRef}
style={imageStyle}
defaultSource={thumb}
tintDefaultSource={!file.localPath}

View file

@ -17,6 +17,7 @@ import {RequestStatus} from 'mattermost-redux/constants';
import {isDocument, isGif, isVideo} from 'app/utils/file';
import {getCacheFile} from 'app/utils/image_cache_manager';
import {previewImageAtIndex} from 'app/utils/images';
import {preventDoubleTap} from 'app/utils/tap';
import FileAttachment from './file_attachment';
@ -119,50 +120,10 @@ export default class FileAttachmentList extends Component {
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,
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
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();
@ -171,22 +132,7 @@ export default class FileAttachmentList extends Component {
handlePreviewPress = preventDoubleTap((idx) => {
this.props.hideOptionsContext();
Keyboard.dismiss();
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,
});
});
previewImageAtIndex(this.props.navigator, this.items, idx, this.galleryFiles);
});
handlePressIn = () => {
@ -231,7 +177,6 @@ export default class FileAttachmentList extends Component {
index={idx}
navigator={navigator}
onCaptureRef={this.handleCaptureRef}
onCapturePreviewRef={this.handleCapturePreviewRef}
onInfoPress={this.handleInfoPress}
onPreviewPress={this.handlePreviewPress}
theme={this.props.theme}

View file

@ -37,6 +37,7 @@ export default class Markdown extends PureComponent {
baseTextStyle: CustomPropTypes.Style,
blockStyles: PropTypes.object,
isEdited: PropTypes.bool,
isReplyPost: PropTypes.bool,
isSearchResult: PropTypes.bool,
navigator: PropTypes.object.isRequired,
onChannelLinkPress: PropTypes.func,
@ -166,6 +167,7 @@ export default class Markdown extends PureComponent {
return (
<MarkdownImage
linkDestination={linkDestination}
isReplyPost={this.props.isReplyPost}
navigator={this.props.navigator}
onLongPress={this.props.onLongPress}
source={src}

View file

@ -5,10 +5,13 @@ import {connect} from 'react-redux';
import {getCurrentUrl} from 'mattermost-redux/selectors/entities/general';
import {getDimensions} from 'app/selectors/device';
import MarkdownImage from './markdown_image';
function mapStateToProps(state) {
return {
...getDimensions(state),
serverURL: getCurrentUrl(state),
};
}

View file

@ -21,17 +21,21 @@ 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 {previewImageAtIndex, calculateDimensions} from 'app/utils/images';
import {normalizeProtocol} from 'app/utils/url';
const MAX_IMAGE_HEIGHT = 150;
const ANDROID_MAX_HEIGHT = 4096;
const ANDROID_MAX_WIDTH = 4096;
const VIEWPORT_IMAGE_OFFSET = 66;
const VIEWPORT_IMAGE_REPLY_OFFSET = 13;
export default class MarkdownImage extends React.Component {
static propTypes = {
children: PropTypes.node,
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
linkDestination: PropTypes.string,
isReplyPost: PropTypes.bool,
navigator: PropTypes.object.isRequired,
onLongPress: PropTypes.func,
serverURL: PropTypes.string.isRequired,
@ -48,8 +52,7 @@ export default class MarkdownImage extends React.Component {
this.state = {
width: 0,
height: MAX_IMAGE_HEIGHT,
maxWidth: Math.MAX_INT,
height: 0,
failed: false,
uri: null,
};
@ -82,26 +85,6 @@ 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;
@ -112,30 +95,20 @@ export default class MarkdownImage extends React.Component {
return source;
};
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) => {
if (!this.mounted) {
return;
}
const {deviceHeight, deviceWidth, isReplyPost} = this.props;
const deviceSize = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
const viewPortWidth = deviceSize - VIEWPORT_IMAGE_OFFSET - (isReplyPost ? VIEWPORT_IMAGE_REPLY_OFFSET : 0);
const dimensions = calculateDimensions(height, width, viewPortWidth);
this.setState({
width,
height,
...dimensions,
originalHeight: height,
originalWidth: width,
});
};
@ -149,16 +122,6 @@ export default class MarkdownImage extends React.Component {
});
};
handleLayout = (event) => {
if (!this.mounted) {
return;
}
this.setState({
maxWidth: event.nativeEvent.layout.width,
});
};
handleLinkPress = () => {
const url = normalizeProtocol(this.props.linkDestination);
@ -190,40 +153,32 @@ export default class MarkdownImage extends React.Component {
};
handlePreviewImage = () => {
const component = this.refs.item;
const {
originalHeight,
originalWidth,
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 (!component) {
return;
if (extension === filename) {
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
filename = `${filename}${ext}`;
}
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,
});
});
const files = [{
caption: filename,
dimensions: {
width: originalWidth,
height: originalHeight,
},
source: {uri},
data: {
localPath: uri,
},
}];
previewImageAtIndex(this.props.navigator, [this.refs.item], 0, files);
};
loadImageSize = (source) => {
@ -243,11 +198,9 @@ export default class MarkdownImage extends React.Component {
render() {
let image = null;
const {uri} = this.state;
if (this.state.width && this.state.height && this.state.maxWidth) {
let {width, height} = this.state;
const {height, uri, width} = this.state;
if (width && height) {
if (Platform.OS === 'android' && (width > ANDROID_MAX_WIDTH || height > ANDROID_MAX_HEIGHT)) {
// Android has a cap on the max image size that can be displayed
@ -266,18 +219,6 @@ export default class MarkdownImage extends React.Component {
</Text>
);
} else {
const maxWidth = this.state.maxWidth;
if (width > maxWidth) {
height = height * (maxWidth / width);
width = maxWidth;
}
const maxHeight = MAX_IMAGE_HEIGHT;
if (height > maxHeight) {
width = width * (maxHeight / height);
height = maxHeight;
}
// React Native complains if we try to pass resizeMode as a style
let source = null;
if (uri) {
@ -326,8 +267,7 @@ export default class MarkdownImage extends React.Component {
return (
<View
ref='item'
style={[style.container, {height: Math.min(this.state.height, MAX_IMAGE_HEIGHT)}]}
onLayout={this.handleLayout}
style={style.container}
>
{image}
</View>

View file

@ -6,17 +6,24 @@ import PropTypes from 'prop-types';
import {
Image,
Linking,
Platform,
Text,
TouchableWithoutFeedback,
View,
} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import Markdown from 'app/components/markdown';
import ProgressiveImage from 'app/components/progressive_image';
import CustomPropTypes from 'app/constants/custom_prop_types';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {previewImageAtIndex, calculateDimensions} from 'app/utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import InteractiveAction from './interactive_action';
const VIEWPORT_IMAGE_CONTAINER_OFFSET = 10;
const VIEWPORT_IMAGE_OFFSET = 32;
const STATUS_COLORS = {
good: '#00c100',
warning: '#dede01',
@ -42,6 +49,20 @@ export default class MessageAttachment extends PureComponent {
this.state = this.getInitState();
}
componentWillMount() {
if (this.props.attachment.image_url) {
ImageCacheManager.cache(null, this.props.attachment.image_url, this.setImageUrl);
}
}
componentDidMount() {
this.mounted = true;
}
componentWillUnmount() {
this.mounted = false;
}
getActionView = (style) => {
const {attachment, postId} = this.props;
const {actions} = attachment;
@ -95,6 +116,7 @@ export default class MessageAttachment extends PureComponent {
uncollapsedText,
text: shouldCollapse ? collapsedText : uncollapsedText,
collapsed: shouldCollapse,
imageUri: null,
};
};
@ -189,12 +211,70 @@ export default class MessageAttachment extends PureComponent {
);
};
handleLayout = (event) => {
if (!this.maxImageWidth) {
const {height, width} = event.nativeEvent.layout;
const viewPortWidth = width > height ? height : width;
this.maxImageWidth = viewPortWidth - VIEWPORT_IMAGE_OFFSET;
}
};
handlePreviewImage = () => {
const {attachment, navigator} = this.props;
const {
imageUri: uri,
originalHeight,
originalWidth,
} = this.state;
const link = attachment.image_url;
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,
dimensions: {
height: originalHeight,
width: originalWidth,
},
source: {uri},
data: {
localPath: uri,
},
}];
previewImageAtIndex(navigator, [this.refs.item], 0, files);
};
openLink = (link) => {
if (Linking.canOpenURL(link)) {
Linking.openURL(link);
}
};
setImageUrl = (imageURL) => {
let imageUri = imageURL;
if (Platform.OS === 'android') {
imageUri = `file://${imageURL}`;
}
Image.getSize(imageUri, (width, height) => {
const dimensions = calculateDimensions(height, width, this.maxImageWidth);
if (this.mounted) {
this.setState({
...dimensions,
originalWidth: width,
originalHeight: height,
imageUri,
});
}
}, () => null);
};
shouldCollapse = () => {
const text = this.props.attachment.text || '';
return (text.match(/\n/g) || []).length >= 5 || text.length > 400;
@ -219,6 +299,12 @@ export default class MessageAttachment extends PureComponent {
theme,
} = this.props;
const {
height,
imageUri,
width,
} = this.state;
const style = getStyleSheet(theme);
let preText;
@ -357,13 +443,23 @@ export default class MessageAttachment extends PureComponent {
const actions = this.getActionView(style);
let image;
if (attachment.image_url) {
if (imageUri) {
image = (
<View style={style.imageContainer}>
<Image
source={{uri: attachment.image_url}}
style={style.image}
/>
<View
ref='item'
style={[style.imageContainer, {width: this.maxImageWidth + VIEWPORT_IMAGE_CONTAINER_OFFSET}]}
>
<TouchableWithoutFeedback
onPress={this.handlePreviewImage}
style={{height, width}}
>
<ProgressiveImage
ref='image'
style={{height, width}}
imageUri={imageUri}
resizeMode='contain'
/>
</TouchableWithoutFeedback>
</View>
);
}
@ -371,7 +467,10 @@ export default class MessageAttachment extends PureComponent {
return (
<View>
{preText}
<View style={[style.container, style.border, borderStyle]}>
<View
onLayout={this.handleLayout}
style={[style.container, style.border, borderStyle]}
>
<View style={{flex: 1, flexDirection: 'row'}}>
{author}
</View>
@ -459,10 +558,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
borderWidth: 1,
borderRadius: 2,
marginTop: 5,
},
image: {
flex: 1,
height: 50,
padding: 5,
},
actionsContainer: {
flex: 1,

View file

@ -6,8 +6,16 @@ import {bindActionCreators} from 'redux';
import {getOpenGraphMetadata} from 'mattermost-redux/actions/posts';
import {getDimensions} from 'app/selectors/device';
import PostAttachmentOpenGraph from './post_attachment_opengraph';
function mapStateToProps(state) {
return {
...getDimensions(state),
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
@ -16,4 +24,4 @@ function mapDispatchToProps(dispatch) {
};
}
export default connect(null, mapDispatchToProps)(PostAttachmentOpenGraph);
export default connect(mapStateToProps, mapDispatchToProps)(PostAttachmentOpenGraph);

View file

@ -4,7 +4,6 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Dimensions,
Image,
Linking,
Platform,
@ -16,16 +15,21 @@ import {
import ProgressiveImage from 'app/components/progressive_image';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {previewImageAtIndex, calculateDimensions} from 'app/utils/images';
import {getNearestPoint} from 'app/utils/opengraph';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const MAX_IMAGE_HEIGHT = 150;
const VIEWPORT_IMAGE_OFFSET = 88;
const VIEWPORT_IMAGE_REPLY_OFFSET = 13;
export default class PostAttachmentOpenGraph extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
getOpenGraphMetadata: PropTypes.func.isRequired,
}).isRequired,
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
isReplyPost: PropTypes.bool,
link: PropTypes.string.isRequired,
navigator: PropTypes.object.isRequired,
@ -66,27 +70,6 @@ export default class PostAttachmentOpenGraph extends PureComponent {
this.mounted = false;
}
calculateLargeImageDimensions = (width, height) => {
const {width: deviceWidth} = Dimensions.get('window');
let maxHeight = MAX_IMAGE_HEIGHT;
let maxWidth = deviceWidth - 88 - (this.props.isReplyPost ? 15 : 0);
if (height <= MAX_IMAGE_HEIGHT) {
maxHeight = height;
} else {
maxHeight = (height / width) * maxWidth;
if (maxHeight > MAX_IMAGE_HEIGHT) {
maxHeight = MAX_IMAGE_HEIGHT;
}
}
if (height > width) {
maxWidth = (width / height) * maxHeight;
}
return {width: maxWidth, height: maxHeight};
};
fetchData(url, openGraphData) {
if (!openGraphData) {
this.props.actions.getOpenGraphMetadata(url);
@ -99,7 +82,7 @@ export default class PostAttachmentOpenGraph extends PureComponent {
}
const bestDimensions = {
width: Dimensions.get('window').width - 88,
width: this.getViewPostWidth(),
height: MAX_IMAGE_HEIGHT,
};
@ -126,51 +109,25 @@ export default class PostAttachmentOpenGraph extends PureComponent {
const uri = `${prefix}${imageUrl}`;
Image.getSize(uri, (width, height) => {
const dimensions = this.calculateLargeImageDimensions(width, height);
const dimensions = calculateDimensions(height, width, this.getViewPostWidth());
if (this.mounted) {
this.setState({
...dimensions,
originalHeight: height,
originalWidth: width,
imageUrl: uri,
});
}
}, () => null);
};
getItemMeasures = (index, cb) => {
const activeComponent = this.refs.item;
getViewPostWidth = () => {
const {deviceHeight, deviceWidth, isReplyPost} = this.props;
const deviceSize = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
const viewPortWidth = deviceSize - VIEWPORT_IMAGE_OFFSET - (isReplyPost ? VIEWPORT_IMAGE_REPLY_OFFSET : 0);
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',
},
});
return viewPortWidth;
};
goToLink = () => {
@ -178,39 +135,33 @@ export default class PostAttachmentOpenGraph extends PureComponent {
};
handlePreviewImage = () => {
const component = this.refs.item;
const {
imageUrl: uri,
openGraphImageUrl: link,
originalWidth,
originalHeight,
} = this.state;
let filename = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?') === -1 ? link.length : link.indexOf('?'));
const extension = filename.split('.').pop();
if (!component) {
return;
if (extension === filename) {
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
filename = `${filename}${ext}`;
}
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();
const files = [{
caption: filename,
dimensions: {
width: originalWidth,
height: originalHeight,
},
source: {uri},
data: {
localPath: uri,
},
}];
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,
});
});
previewImageAtIndex(this.props.navigator, [this.refs.item], 0, files);
};
render() {
@ -274,7 +225,7 @@ export default class PostAttachmentOpenGraph extends PureComponent {
ref='image'
style={[style.image, {width, height}]}
imageUri={imageUrl}
resizeMode='cover'
resizeMode='contain'
/>
</TouchableWithoutFeedback>
</View>

View file

@ -398,6 +398,7 @@ export default class PostBody extends PureComponent {
isFailed,
isPending,
isPostAddChannelMember,
isReplyPost,
isSearchResult,
isSystemMessage,
message,
@ -470,6 +471,7 @@ export default class PostBody extends PureComponent {
baseTextStyle={messageStyle}
blockStyles={blockStyles}
isEdited={hasBeenEdited}
isReplyPost={isReplyPost}
isSearchResult={isSearchResult}
navigator={navigator}
onLongPress={this.showOptionsContext}

View file

@ -19,10 +19,12 @@ 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 {previewImageAtIndex, calculateDimensions} from 'app/utils/images';
import {getYouTubeVideoId, isImageLink, isYoutubeLink} from 'app/utils/url';
const MAX_IMAGE_HEIGHT = 150;
const VIEWPORT_IMAGE_OFFSET = 66;
const VIEWPORT_IMAGE_REPLY_OFFSET = 13;
const MAX_YOUTUBE_IMAGE_HEIGHT = 150;
let MessageAttachments;
let PostAttachmentOpenGraph;
@ -97,18 +99,18 @@ export default class PostBodyAdditionalContent extends PureComponent {
}
};
calculateDimensions = (width, height) => {
calculateYouTubeImageDimensions = (width, height) => {
const {deviceHeight, deviceWidth} = this.props;
let maxHeight = MAX_IMAGE_HEIGHT;
let maxHeight = MAX_YOUTUBE_IMAGE_HEIGHT;
const deviceSize = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
let maxWidth = deviceSize - 78;
if (height <= MAX_IMAGE_HEIGHT) {
if (height <= MAX_YOUTUBE_IMAGE_HEIGHT) {
maxHeight = height;
} else {
maxHeight = (height / width) * maxWidth;
if (maxHeight > MAX_IMAGE_HEIGHT) {
maxHeight = MAX_IMAGE_HEIGHT;
if (maxHeight > MAX_YOUTUBE_IMAGE_HEIGHT) {
maxHeight = MAX_YOUTUBE_IMAGE_HEIGHT;
}
}
@ -151,7 +153,7 @@ export default class PostBodyAdditionalContent extends PureComponent {
generateToggleableEmbed = (isImage, isYouTube) => {
const {link} = this.props;
const {width, height, uri} = this.state;
const imgHeight = height || MAX_IMAGE_HEIGHT;
const imgHeight = height;
if (link) {
if (isYouTube) {
@ -196,7 +198,7 @@ export default class PostBodyAdditionalContent extends PureComponent {
ref='image'
style={[styles.image, {width, height: imgHeight}]}
defaultSource={{uri}}
resizeMode='cover'
resizeMode='contain'
onError={this.handleLinkLoadError}
/>
</View>
@ -209,7 +211,9 @@ export default class PostBodyAdditionalContent extends PureComponent {
};
getImageSize = (path) => {
const {link} = this.props;
const {deviceHeight, deviceWidth, link, isReplyPost} = this.props;
const deviceSize = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
const viewPortWidth = deviceSize - VIEWPORT_IMAGE_OFFSET - (isReplyPost ? VIEWPORT_IMAGE_REPLY_OFFSET : 0);
if (link && path) {
let prefix = '';
@ -228,27 +232,24 @@ export default class PostBodyAdditionalContent extends PureComponent {
return;
}
const dimensions = this.calculateDimensions(width, height);
this.setState({...dimensions, linkLoaded: true, uri});
let dimensions;
if (isYoutubeLink(link)) {
dimensions = this.calculateYouTubeImageDimensions(width, height);
} else {
dimensions = calculateDimensions(height, width, viewPortWidth);
}
this.setState({
...dimensions,
originalHeight: height,
originalWidth: width,
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 = () => {
const {
postId,
@ -285,11 +286,6 @@ export default class PostBodyAdditionalContent extends PureComponent {
return null;
};
getPreviewProps = () => {
const previewComponent = this.refs.image;
return previewComponent ? {...previewComponent.props} : {};
};
getYouTubeTime = (link) => {
const timeRegex = /[\\?&](t|start|time_continue)=([0-9]+h)?([0-9]+m)?([0-9]+s?)/;
@ -319,54 +315,31 @@ export default class PostBodyAdditionalContent extends PureComponent {
return ticks;
};
goToImagePreview = (passProps) => {
this.props.navigator.showModal({
screen: 'ImagePreview',
title: '',
animationType: 'none',
passProps,
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
screenBackgroundColor: 'transparent',
modalPresentationStyle: 'overCurrentContext',
},
});
};
handleLinkLoadError = () => {
this.setState({linkLoadError: true});
};
handlePreviewImage = () => {
const component = this.refs.item;
const {link, navigator} = this.props;
const {
originalHeight,
originalWidth,
uri,
} = this.state;
const filename = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?') === -1 ? link.length : link.indexOf('?'));
const files = [{
caption: filename,
source: {uri},
dimensions: {
width: originalWidth,
height: originalHeight,
},
data: {
localPath: uri,
},
}];
if (!component) {
return;
}
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,
});
});
previewImageAtIndex(navigator, [this.refs.item], 0, files);
};
playYouTubeVideo = () => {

View file

@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
import {
Alert,
Animated,
Image,
Platform,
SafeAreaView,
ScrollView,
@ -30,6 +31,7 @@ import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachm
import {NavigationTypes, PermissionTypes} from 'app/constants';
import {isDocument, isVideo} from 'app/utils/file';
import {emptyFunction} from 'app/utils/general';
import {calculateDimensions} from 'app/utils/images';
import Downloader from './downloader';
import VideoPreview from './video_preview';
@ -49,7 +51,6 @@ export default class ImagePreview extends PureComponent {
deviceWidth: PropTypes.number.isRequired,
files: PropTypes.array,
getItemMeasures: PropTypes.func.isRequired,
getPreviewProps: PropTypes.func.isRequired,
index: PropTypes.number.isRequired,
navigator: PropTypes.object,
origin: PropTypes.object,
@ -179,10 +180,23 @@ export default class ImagePreview extends PureComponent {
};
getSwipeableStyle = () => {
const {deviceHeight, deviceWidth} = this.props;
const {origin, target} = this.state;
const {deviceHeight, deviceWidth, files} = this.props;
const {origin, target, index} = this.state;
const inputRange = [0, 1];
let toHeight = deviceHeight;
let toWidth = deviceWidth;
if (files[index].dimensions) {
const {height, width} = files[index].dimensions;
if (width < deviceWidth) {
toWidth = width;
}
if (height < deviceWidth) {
toHeight = height;
}
}
return {
left: this.openAnim.interpolate({
inputRange,
@ -194,11 +208,11 @@ export default class ImagePreview extends PureComponent {
}),
width: this.openAnim.interpolate({
inputRange,
outputRange: [origin.width, deviceWidth],
outputRange: [origin.width, toWidth],
}),
height: this.openAnim.interpolate({
inputRange,
outputRange: [origin.height, deviceHeight],
outputRange: [origin.height, toHeight],
}),
};
};
@ -323,6 +337,7 @@ export default class ImagePreview extends PureComponent {
return (
<Gallery
errorComponent={this.renderOtherItems}
imageComponent={this.renderImageComponent}
images={this.props.files}
initialPage={this.state.index}
onLayout={this.handleGalleryLayout}
@ -364,6 +379,27 @@ export default class ImagePreview extends PureComponent {
);
}
renderImageComponent = (imageProps, imageDimensions) => {
if (imageDimensions) {
const {height, width} = imageDimensions;
const {style, ...otherProps} = imageProps;
const flattenStyle = StyleSheet.flatten(style);
const calculatedDimensions = calculateDimensions(height, width, flattenStyle.width, flattenStyle.height);
const imageStyle = {...flattenStyle, ...calculatedDimensions};
return (
<View style={[style, {justifyContent: 'center', alignItems: 'center'}]}>
<Image
{...otherProps}
style={imageStyle}
/>
</View>
);
}
return null;
}
renderOtherItems = (index) => {
const {files} = this.props;
const file = files[index];
@ -382,23 +418,19 @@ export default class ImagePreview extends PureComponent {
};
renderSelectedItem = () => {
const {hide, index} = this.state;
const {hide} = this.state;
const file = this.getCurrentFile();
if (hide || isDocument(file.data) || isVideo(file.data)) {
if (hide || !file || !file.source || !file.source.uri || isDocument(file.data) || isVideo(file.data)) {
return null;
}
const {getPreviewProps} = this.props;
const containerStyle = this.getSwipeableStyle();
const previewProps = getPreviewProps(index);
Reflect.deleteProperty(previewProps, 'thumbnailUri');
return (
<ScrollView scrollEnabled={false}>
<Animated.View style={[style.center, style.flex, containerStyle]}>
<ProgressiveImage
{...previewProps}
imageUri={file.source.uri}
style={[StyleSheet.absoluteFill, style.fullWidth]}
resizeMode='contain'
/>

90
app/utils/images.js Normal file
View file

@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
let previewComponents;
const IMAGE_MAX_HEIGHT = 350;
const IMAGE_MIN_DIMENSION = 50;
export const calculateDimensions = (height, width, viewPortWidth = 0, viewPortHeight = 0) => {
const ratio = height / width;
const heightRatio = width / height;
let imageWidth = width;
let imageHeight = height;
if (width > viewPortWidth) {
imageWidth = viewPortWidth;
imageHeight = imageWidth * ratio;
} else if (width < IMAGE_MIN_DIMENSION) {
imageWidth = IMAGE_MIN_DIMENSION;
imageHeight = imageWidth * ratio;
}
if (
(
imageHeight > IMAGE_MAX_HEIGHT ||
(viewPortHeight && imageHeight > viewPortHeight)
) && viewPortHeight <= IMAGE_MAX_HEIGHT
) {
imageHeight = viewPortHeight || IMAGE_MAX_HEIGHT;
imageWidth = imageHeight * heightRatio;
} else if (imageHeight < IMAGE_MIN_DIMENSION) {
imageHeight = IMAGE_MIN_DIMENSION;
imageWidth = imageHeight * heightRatio;
}
return {
height: imageHeight,
width: imageWidth,
};
};
export function previewImageAtIndex(navigator, components, index, files) {
previewComponents = components;
const component = components[index];
if (component) {
component.measure((rx, ry, width, height, x, y) => {
goToImagePreview(
navigator,
{
index,
origin: {x, y, width, height},
target: {x: 0, y: 0, opacity: 1},
files,
getItemMeasures,
}
);
});
}
}
function goToImagePreview(navigator, passProps) {
navigator.showModal({
screen: 'ImagePreview',
title: '',
animationType: 'none',
passProps,
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
screenBackgroundColor: 'transparent',
modalPresentationStyle: 'overCurrentContext',
},
});
}
function getItemMeasures(index, cb) {
const activeComponent = previewComponents[index];
if (!activeComponent) {
cb(null);
return;
}
activeComponent.measure((rx, ry, width, height, x, y) => {
cb({
origin: {x, y, width, height},
});
});
}

59
app/utils/images.test.js Normal file
View file

@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {calculateDimensions} from 'app/utils/images';
const PORTRAIT_VIEWPORT = 315;
const IMAGE_MAX_HEIGHT = 350;
const IMAGE_MIN_DIMENSION = 50;
describe('Images calculateDimensions', () => {
it('image smaller than 50x50 should return 50x50', () => {
const {height, width} = calculateDimensions(20, 20, PORTRAIT_VIEWPORT);
expect(height).toEqual(IMAGE_MIN_DIMENSION);
expect(width).toEqual(IMAGE_MIN_DIMENSION);
});
it('images with height below 50 should return height of 50', () => {
const {height} = calculateDimensions(45, 100, PORTRAIT_VIEWPORT);
expect(height).toEqual(IMAGE_MIN_DIMENSION);
});
it('images with width below 50 should return width of 50', () => {
const {width} = calculateDimensions(100, 45, PORTRAIT_VIEWPORT);
expect(width).toEqual(IMAGE_MIN_DIMENSION);
});
it('images with that are 50x50 should return the same size', () => {
const {height, width} = calculateDimensions(50, 50, PORTRAIT_VIEWPORT);
expect(height).toEqual(IMAGE_MIN_DIMENSION);
expect(width).toEqual(IMAGE_MIN_DIMENSION);
});
it('images with smaller sizes than the max allowed should return the same size', () => {
const {height, width} = calculateDimensions(75, 150, PORTRAIT_VIEWPORT);
expect(height).toEqual(75);
expect(width).toEqual(150);
});
it('images with that have a width of the same size as the viewPort return the same size', () => {
const {height, width} = calculateDimensions(75, PORTRAIT_VIEWPORT, PORTRAIT_VIEWPORT);
expect(height).toEqual(75);
expect(width).toEqual(PORTRAIT_VIEWPORT);
});
it('large images with more height than width should return a MAX height of 350', () => {
const {height} = calculateDimensions(1920, 1080, PORTRAIT_VIEWPORT);
expect(height).toEqual(IMAGE_MAX_HEIGHT);
});
it('large images with more width than height should return the MAX width equal to the viewPort', () => {
const {width} = calculateDimensions(1080, 1920, PORTRAIT_VIEWPORT);
expect(width).toEqual(PORTRAIT_VIEWPORT);
});
it('large images with the viewPort height defined should return the MAX height equal to the viewPort Height', () => {
const {height} = calculateDimensions(1920, 1080, PORTRAIT_VIEWPORT, 340);
expect(height).toEqual(340);
});
});