MM-14030 Don't show large gifs in posts (#2750)

* MM-14030 Don't show large gifs in posts

* Re-add default argument
This commit is contained in:
Harrison Healey 2019-05-01 09:34:45 -04:00 committed by GitHub
parent df2ecf3409
commit 1173ca3d97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 209 additions and 75 deletions

View file

@ -43,7 +43,7 @@ export default class Markdown extends PureComponent {
baseTextStyle: CustomPropTypes.Style,
blockStyles: PropTypes.object,
channelMentions: PropTypes.object,
imageMetadata: PropTypes.object,
imagesMetadata: PropTypes.object,
isEdited: PropTypes.bool,
isReplyPost: PropTypes.bool,
isSearchResult: PropTypes.bool,
@ -186,7 +186,7 @@ export default class Markdown extends PureComponent {
return (
<MarkdownImage
linkDestination={linkDestination}
imageMetadata={this.props.imageMetadata}
imagesMetadata={this.props.imagesMetadata}
isReplyPost={this.props.isReplyPost}
navigator={this.props.navigator}
source={src}

View file

@ -21,7 +21,7 @@ import CustomPropTypes from 'app/constants/custom_prop_types';
import mattermostManaged from 'app/mattermost_managed';
import BottomSheet from 'app/utils/bottom_sheet';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {previewImageAtIndex, calculateDimensions} from 'app/utils/images';
import {previewImageAtIndex, calculateDimensions, isGifTooLarge} from 'app/utils/images';
import {normalizeProtocol} from 'app/utils/url';
import brokenImageIcon from 'assets/images/icons/brokenimage.png';
@ -36,7 +36,7 @@ export default class MarkdownImage extends React.Component {
children: PropTypes.node,
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
imageMetadata: PropTypes.object,
imagesMetadata: PropTypes.object,
linkDestination: PropTypes.string,
isReplyPost: PropTypes.bool,
navigator: PropTypes.object.isRequired,
@ -52,7 +52,7 @@ export default class MarkdownImage extends React.Component {
constructor(props) {
super(props);
const dimensions = props?.imageMetadata?.[props.source];
const dimensions = props.imagesMetadata?.[props.source];
this.state = {
originalHeight: dimensions?.height || 0,
originalWidth: dimensions?.width || 0,
@ -63,26 +63,29 @@ export default class MarkdownImage extends React.Component {
this.mounted = false;
}
componentWillMount() {
componentDidMount() {
this.mounted = true;
ImageCacheManager.cache(null, this.getSource(), this.setImageUrl);
}
componentDidMount() {
this.mounted = true;
static getDerivedStateFromProps(props) {
const imageMetadata = props.imagesMetadata?.[props.source];
if (imageMetadata) {
return {
originalHeight: imageMetadata.height,
originalWidth: imageMetadata.width,
};
}
return null;
}
componentWillReceiveProps(nextProps) {
if (this.props.source !== nextProps.source) {
const dimensions = nextProps?.imageMetadata?.[nextProps.source];
this.setState({
failed: false,
originalHeight: dimensions?.height || 0,
originalWidth: dimensions?.width || 0,
});
componentDidUpdate(prevProps) {
if (this.props.source !== prevProps.source) {
// getSource also depends on serverURL, but that shouldn't change while this is mounted
ImageCacheManager.cache(null, this.getSource(nextProps), this.setImageUrl);
ImageCacheManager.cache(null, this.getSource(), this.setImageUrl);
}
}
@ -90,11 +93,11 @@ export default class MarkdownImage extends React.Component {
this.mounted = false;
}
getSource = (props = this.props) => {
let source = props.source;
getSource = () => {
let source = this.props.source;
if (source.startsWith('/')) {
source = props.serverURL + '/' + source;
source = this.props.serverURL + '/' + source;
}
return source;
@ -112,6 +115,7 @@ export default class MarkdownImage extends React.Component {
}
this.setState({
failed: false,
originalHeight: height,
originalWidth: width,
});
@ -203,6 +207,10 @@ export default class MarkdownImage extends React.Component {
};
render() {
if (isGifTooLarge(this.props.imagesMetadata?.[this.props.source])) {
return null;
}
let image = null;
const {originalHeight, originalWidth, uri} = this.state;
const {height, width} = calculateDimensions(originalHeight, originalWidth, this.getViewPortWidth());

View file

@ -86,7 +86,7 @@ export default class AttachmentFields extends PureComponent {
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
imageMetadata={metadata?.images}
imagesMetadata={metadata?.images}
value={(field.value || '')}
navigator={navigator}
onPermalinkPress={onPermalinkPress}

View file

@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
import {Image, TouchableWithoutFeedback, View} from 'react-native';
import ProgressiveImage from 'app/components/progressive_image';
import {previewImageAtIndex, calculateDimensions} from 'app/utils/images';
import {isGifTooLarge, previewImageAtIndex, calculateDimensions} from 'app/utils/images';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
@ -17,8 +17,8 @@ export default class AttachmentImage extends PureComponent {
static propTypes = {
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
imageMetadata: PropTypes.object,
imageUrl: PropTypes.string,
metadata: PropTypes.object,
navigator: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
};
@ -34,12 +34,11 @@ export default class AttachmentImage extends PureComponent {
componentDidMount() {
this.mounted = true;
const {imageUrl, metadata} = this.props;
const {imageUrl, imageMetadata} = this.props;
this.setViewPortMaxWidth();
if (metadata?.images?.[imageUrl]) {
const img = metadata.images[imageUrl];
this.setImageDimensionsFromMeta(null, img);
if (imageMetadata) {
this.setImageDimensionsFromMeta(null, imageMetadata);
}
if (imageUrl) {
@ -88,16 +87,16 @@ export default class AttachmentImage extends PureComponent {
}
};
setImageDimensionsFromMeta = (imageUri, img) => {
const dimensions = calculateDimensions(img.height, img.width, this.maxImageWidth);
this.setImageDimensions(imageUri, dimensions, img.width, img.height);
setImageDimensionsFromMeta = (imageUri, imageMetadata) => {
const dimensions = calculateDimensions(imageMetadata.height, imageMetadata.width, this.maxImageWidth);
this.setImageDimensions(imageUri, dimensions, imageMetadata.width, imageMetadata.height);
};
setImageUrl = (imageURL) => {
const {imageUrl: attachmentImageUrl, metadata} = this.props;
const {imageMetadata} = this.props;
if (metadata?.images?.[attachmentImageUrl]) {
this.setImageDimensionsFromMeta(imageURL, metadata.images[attachmentImageUrl]);
if (imageMetadata) {
this.setImageDimensionsFromMeta(imageURL, imageMetadata);
return;
}
@ -114,10 +113,10 @@ export default class AttachmentImage extends PureComponent {
};
render() {
const {theme} = this.props;
const {imageMetadata, theme} = this.props;
const {hasImage, height, imageUri, width} = this.state;
if (!hasImage) {
if (!hasImage || isGifTooLarge(imageMetadata)) {
return null;
}

View file

@ -40,7 +40,7 @@ export default class AttachmentPreText extends PureComponent {
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
imageMetadata={metadata?.images}
imagesMetadata={metadata?.images}
value={value}
navigator={navigator}
onPermalinkPress={onPermalinkPress}

View file

@ -95,7 +95,7 @@ export default class AttachmentText extends PureComponent {
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
imageMetadata={metadata?.images}
imagesMetadata={metadata?.images}
value={value}
navigator={navigator}
onPermalinkPress={onPermalinkPress}

View file

@ -12,7 +12,7 @@ import AttachmentActions from './attachment_actions';
import AttachmentAuthor from './attachment_author';
import AttachmentFields from './attachment_fields';
import AttachmentImage from './attachment_image';
import AttachmentPreText from './attachement_pretext';
import AttachmentPreText from './attachment_pretext';
import AttachmentText from './attachment_text';
import AttachmentThumbnail from './attachment_thumbnail';
import AttachmentTitle from './attachment_title';
@ -119,7 +119,7 @@ export default class MessageAttachment extends PureComponent {
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
imageUrl={attachment.image_url}
metadata={metadata}
imageMetadata={metadata?.images?.[attachment.image_url]}
navigator={navigator}
theme={theme}
/>

View file

@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native';
import ProgressiveImage from 'app/components/progressive_image';
import {isGifTooLarge} from 'app/utils/images';
export default class PostAttachmentImage extends React.PureComponent {
static propTypes = {
height: PropTypes.number.isRequired,
imageMetadata: PropTypes.object,
onError: PropTypes.func.isRequired,
onImagePress: PropTypes.func.isRequired,
uri: PropTypes.string,
width: PropTypes.number.isRequired,
};
static defaultProps = {
frameCount: 0,
};
constructor(props) {
super(props);
this.image = React.createRef();
}
handlePress = () => {
this.props.onImagePress(this.image.current);
};
render() {
if (isGifTooLarge(this.props.imageMetadata)) {
return null;
}
// Note that TouchableWithoutFeedback only works if its child is a View
return (
<TouchableWithoutFeedback
onPress={this.handlePress}
style={[styles.imageContainer, {height: this.props.height}]}
>
<View ref={this.image}>
<ProgressiveImage
style={[styles.image, {width: this.props.width, height: this.props.height}]}
defaultSource={{uri: this.props.uri}}
resizeMode='contain'
onError={this.props.onError}
/>
</View>
</TouchableWithoutFeedback>
);
}
}
const styles = StyleSheet.create({
imageContainer: {
alignItems: 'flex-start',
justifyContent: 'flex-start',
marginBottom: 6,
marginTop: 10,
},
image: {
alignItems: 'center',
borderRadius: 3,
justifyContent: 'center',
marginVertical: 1,
},
});

View file

@ -28,7 +28,7 @@ export default class PostAttachmentOpenGraph extends PureComponent {
}).isRequired,
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
imageMetadata: PropTypes.object,
imagesMetadata: PropTypes.object,
isReplyPost: PropTypes.bool,
link: PropTypes.string.isRequired,
navigator: PropTypes.object.isRequired,
@ -78,7 +78,7 @@ export default class PostAttachmentOpenGraph extends PureComponent {
};
}
const {imageMetadata} = this.props;
const {imagesMetadata} = this.props;
const bestDimensions = {
width: this.getViewPostWidth(),
height: MAX_IMAGE_HEIGHT,
@ -87,8 +87,8 @@ export default class PostAttachmentOpenGraph extends PureComponent {
const bestImage = getNearestPoint(bestDimensions, data.images, 'width', 'height');
const imageUrl = bestImage.secure_url || bestImage.url;
let ogImage;
if (imageMetadata && imageMetadata[imageUrl]) {
ogImage = imageMetadata[imageUrl];
if (imagesMetadata && imagesMetadata[imageUrl]) {
ogImage = imagesMetadata[imageUrl];
}
if (!ogImage) {
@ -124,12 +124,12 @@ export default class PostAttachmentOpenGraph extends PureComponent {
};
getImageSize = (imageUrl) => {
const {imageMetadata, openGraphData} = this.props;
const {imagesMetadata, openGraphData} = this.props;
const {openGraphImageUrl} = this.state;
let ogImage;
if (imageMetadata && imageMetadata[openGraphImageUrl]) {
ogImage = imageMetadata[openGraphImageUrl];
if (imagesMetadata && imagesMetadata[openGraphImageUrl]) {
ogImage = imagesMetadata[openGraphImageUrl];
}
if (!ogImage) {

View file

@ -28,7 +28,7 @@ describe('PostAttachmentOpenGraph', () => {
},
deviceHeight: 600,
deviceWidth: 400,
imageMetadata: {
imagesMetadata: {
'https://www.mattermost.org/wp-content/uploads/2016/03/logoHorizontal_WS.png': {
width: 1165,
height: 265,

View file

@ -396,7 +396,7 @@ export default class PostBody extends PureComponent {
baseTextStyle={messageStyle}
blockStyles={blockStyles}
channelMentions={postProps.channel_mentions}
imageMetadata={metadata?.images}
imagesMetadata={metadata?.images}
isEdited={hasBeenEdited}
isReplyPost={isReplyPost}
isSearchResult={isSearchResult}

View file

@ -9,13 +9,12 @@ import {
Linking,
Platform,
StyleSheet,
TouchableWithoutFeedback,
TouchableOpacity,
View,
} from 'react-native';
import {YouTubeStandaloneAndroid, YouTubeStandaloneIOS} from 'react-native-youtube';
import {intlShape} from 'react-intl';
import PostAttachmentImage from 'app/components/post_attachment_image';
import ProgressiveImage from 'app/components/progressive_image';
import CustomPropTypes from 'app/constants/custom_prop_types';
@ -199,7 +198,7 @@ export default class PostBodyAdditionalContent extends PureComponent {
link={link}
navigator={navigator}
openGraphData={openGraphData}
imageMetadata={metadata && metadata.images}
imagesMetadata={metadata && metadata.images}
theme={theme}
/>
);
@ -215,7 +214,6 @@ export default class PostBodyAdditionalContent extends PureComponent {
link = shortenedLink;
}
const {width, height, uri} = this.state;
const imgHeight = height;
if (link) {
if (isYouTube) {
@ -225,14 +223,13 @@ export default class PostBodyAdditionalContent extends PureComponent {
return (
<TouchableOpacity
style={[styles.imageContainer, {height: imgHeight || MAX_YOUTUBE_IMAGE_HEIGHT}]}
{...this.responder}
style={[styles.imageContainer, {height: height || MAX_YOUTUBE_IMAGE_HEIGHT}]}
onPress={this.playYouTubeVideo}
>
<ProgressiveImage
isBackgroundImage={true}
imageUri={imgUrl}
style={[styles.image, {width: width || MAX_YOUTUBE_IMAGE_WIDTH, height: imgHeight || MAX_YOUTUBE_IMAGE_HEIGHT}]}
style={[styles.image, {width: width || MAX_YOUTUBE_IMAGE_WIDTH, height: height || MAX_YOUTUBE_IMAGE_HEIGHT}]}
thumbnailUri={thumbUrl}
resizeMode='cover'
onError={this.handleLinkLoadError}
@ -252,22 +249,17 @@ export default class PostBodyAdditionalContent extends PureComponent {
}
if (isImage) {
const imageMetadata = this.props.metadata?.images?.[link];
return (
<TouchableWithoutFeedback
onPress={this.handlePreviewImage}
style={[styles.imageContainer, {height: imgHeight || MAX_YOUTUBE_IMAGE_HEIGHT}]}
{...this.responder}
>
<View ref='item'>
<ProgressiveImage
ref='image'
style={[styles.image, {width, height: imgHeight || MAX_YOUTUBE_IMAGE_HEIGHT}]}
defaultSource={{uri}}
resizeMode='contain'
onError={this.handleLinkLoadError}
/>
</View>
</TouchableWithoutFeedback>
<PostAttachmentImage
height={height || MAX_YOUTUBE_IMAGE_HEIGHT}
imageMetadata={imageMetadata}
onImagePress={this.handlePreviewImage}
onError={this.handleLinkLoadError}
uri={uri}
width={width}
/>
);
}
}
@ -405,7 +397,7 @@ export default class PostBodyAdditionalContent extends PureComponent {
this.setState({linkLoadError: true});
};
handlePreviewImage = () => {
handlePreviewImage = (imageRef) => {
const {shortenedLink} = this.state;
let {link} = this.props;
const {navigator} = this.props;
@ -430,7 +422,7 @@ export default class PostBodyAdditionalContent extends PureComponent {
},
}];
previewImageAtIndex(navigator, [this.refs.item], 0, files);
previewImageAtIndex(navigator, [imageRef], 0, files);
};
playYouTubeVideo = () => {

View file

@ -104,3 +104,19 @@ function getItemMeasures(index, cb) {
});
});
}
const MAX_GIF_SIZE = 100 * 1024 * 1024;
// isGifTooLarge returns true if we think that the GIF may cause the device to run out of memory when rendered
// based on the image's dimensions and frame count.
export function isGifTooLarge(imageMetadata) {
if (imageMetadata?.format !== 'gif') {
// Not a gif or from an older server that doesn't count frames
return false;
}
const {frame_count: frameCount, height, width} = imageMetadata;
// Try to estimate the in-memory size of the gif to prevent the device out of memory
return width * height * frameCount > MAX_GIF_SIZE;
}

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {calculateDimensions} from 'app/utils/images';
import {calculateDimensions, isGifTooLarge} from 'app/utils/images';
import {
IMAGE_MAX_HEIGHT,
IMAGE_MIN_DIMENSION,
@ -88,3 +88,49 @@ describe('Images calculateDimensions', () => {
expect(height).toEqual(500);
});
});
describe('isGifTooLarge', () => {
const testCases = [
{
name: 'no image metadata',
imageMetadata: null,
expected: false,
},
{
name: 'not a gif',
imageMetadata: {format: 'png', frame_count: 0, height: 1000, width: 1000},
expected: false,
},
{
name: 'an image without a format/frame count',
imageMetadata: {height: 1000, width: 1000},
expected: false,
},
{
name: 'a static gif',
imageMetadata: {format: 'gif', frame_count: 1, height: 600, width: 500},
expected: false,
},
{
name: 'a regular animated gif',
imageMetadata: {format: 'gif', frame_count: 40, height: 600, width: 500},
expected: false,
},
{
name: 'a very large animated gif',
imageMetadata: {format: 'gif', frame_count: 40, height: 100000, width: 100000},
expected: true,
},
{
name: 'a very long animated gif',
imageMetadata: {format: 'gif', frame_count: 2000, height: 1000, width: 1000},
expected: true,
},
];
for (const testCase of testCases) {
test(testCase.name, () => {
expect(isGifTooLarge(testCase.imageMetadata)).toBe(testCase.expected);
});
}
});