From b47c7db4dc4e96c1a9b0d6fa11bf9ac07713cb85 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Fri, 20 Jul 2018 13:34:46 -0400 Subject: [PATCH] Fix cropped images (#1936) * Fix cropped images * Unit tests --- .../file_attachment_list/file_attachment.js | 11 -- .../file_attachment_icon.js | 10 -- .../file_attachment_image.js | 10 -- .../file_attachment_list.js | 59 +------- app/components/markdown/markdown.js | 2 + .../markdown/markdown_image/index.js | 3 + .../markdown/markdown_image/markdown_image.js | 142 +++++------------- .../message_attachments/message_attachment.js | 118 +++++++++++++-- .../post_attachment_opengraph/index.js | 10 +- .../post_attachment_opengraph.js | 125 +++++---------- app/components/post_body/post_body.js | 2 + .../post_body_additional_content.js | 121 ++++++--------- app/screens/image_preview/image_preview.js | 56 +++++-- app/utils/images.js | 90 +++++++++++ app/utils/images.test.js | 59 ++++++++ 15 files changed, 444 insertions(+), 374 deletions(-) create mode 100644 app/utils/images.js create mode 100644 app/utils/images.test.js diff --git a/app/components/file_attachment_list/file_attachment.js b/app/components/file_attachment_list/file_attachment.js index 8957acc88..4fe3e5b8c 100644 --- a/app/components/file_attachment_list/file_attachment.js +++ b/app/components/file_attachment_list/file_attachment.js @@ -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 { @@ -124,7 +114,6 @@ export default class FileAttachment extends PureComponent { diff --git a/app/components/file_attachment_list/file_attachment_icon.js b/app/components/file_attachment_list/file_attachment_icon.js index fc9c9dfdf..95e6a7791 100644 --- a/app/components/file_attachment_list/file_attachment_icon.js +++ b/app/components/file_attachment_list/file_attachment_icon.js @@ -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}]} > diff --git a/app/components/file_attachment_list/file_attachment_image.js b/app/components/file_attachment_list/file_attachment_image.js index 204e3f019..b365d9bc6 100644 --- a/app/components/file_attachment_list/file_attachment_image.js +++ b/app/components/file_attachment_list/file_attachment_image.js @@ -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'}]} > { - 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} diff --git a/app/components/markdown/markdown.js b/app/components/markdown/markdown.js index 204e6ab4f..c638a79e6 100644 --- a/app/components/markdown/markdown.js +++ b/app/components/markdown/markdown.js @@ -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 ( { - 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 { ); } 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 ( {image} diff --git a/app/components/message_attachments/message_attachment.js b/app/components/message_attachments/message_attachment.js index 4ad1fdc9f..3f1fb07a8 100644 --- a/app/components/message_attachments/message_attachment.js +++ b/app/components/message_attachments/message_attachment.js @@ -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 = ( - - + + + + ); } @@ -371,7 +467,10 @@ export default class MessageAttachment extends PureComponent { return ( {preText} - + {author} @@ -459,10 +558,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { borderWidth: 1, borderRadius: 2, marginTop: 5, - }, - image: { - flex: 1, - height: 50, + padding: 5, }, actionsContainer: { flex: 1, diff --git a/app/components/post_attachment_opengraph/index.js b/app/components/post_attachment_opengraph/index.js index 3c003ff32..f422ddf5e 100644 --- a/app/components/post_attachment_opengraph/index.js +++ b/app/components/post_attachment_opengraph/index.js @@ -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); diff --git a/app/components/post_attachment_opengraph/post_attachment_opengraph.js b/app/components/post_attachment_opengraph/post_attachment_opengraph.js index 15da1affc..ef0717080 100644 --- a/app/components/post_attachment_opengraph/post_attachment_opengraph.js +++ b/app/components/post_attachment_opengraph/post_attachment_opengraph.js @@ -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' /> diff --git a/app/components/post_body/post_body.js b/app/components/post_body/post_body.js index 35894f98a..65b5be1cf 100644 --- a/app/components/post_body/post_body.js +++ b/app/components/post_body/post_body.js @@ -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} diff --git a/app/components/post_body_additional_content/post_body_additional_content.js b/app/components/post_body_additional_content/post_body_additional_content.js index 6bd72d063..3e4d95343 100644 --- a/app/components/post_body_additional_content/post_body_additional_content.js +++ b/app/components/post_body_additional_content/post_body_additional_content.js @@ -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} /> @@ -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 = () => { diff --git a/app/screens/image_preview/image_preview.js b/app/screens/image_preview/image_preview.js index be92d21b5..5bd403d2d 100644 --- a/app/screens/image_preview/image_preview.js +++ b/app/screens/image_preview/image_preview.js @@ -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 ( { + 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 ( + + + + ); + } + + 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 ( diff --git a/app/utils/images.js b/app/utils/images.js new file mode 100644 index 000000000..1b7f0419d --- /dev/null +++ b/app/utils/images.js @@ -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}, + }); + }); +} diff --git a/app/utils/images.test.js b/app/utils/images.test.js new file mode 100644 index 000000000..9f1a57f6c --- /dev/null +++ b/app/utils/images.test.js @@ -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); + }); +});