Commit 99aa6397 authored by Sudheer's avatar Sudheer Committed by Harrison Healey
Browse files

Mm 13107 Add backwards compatibility for post-metadata (#2072)

* MM-13107 Add backwards compatibility for metadata

* Fix review comments
parent e8046fa9
......@@ -2,6 +2,7 @@
exports[`components/MarkdownImage should match snapshot 1`] = `
<img
onError={[Function]}
onLoad={[Function]}
src="https://something.com/image.png"
style={
......
......@@ -39,6 +39,14 @@ export default class FileAttachmentList extends React.Component {
isEmbedVisible: PropTypes.bool,
locale: PropTypes.string.isRequired,
actions: PropTypes.shape({
/*
* Function to get file metadata for a post
*/
getMissingFilesForPost: PropTypes.func.isRequired,
}).isRequired,
}
constructor(props) {
......@@ -49,6 +57,12 @@ export default class FileAttachmentList extends React.Component {
this.state = {showPreviewModal: false, startImgIndex: 0};
}
componentDidMount() {
if ((this.props.post.file_ids || this.props.post.filenames) && !this.props.post.metadata) {
this.props.actions.getMissingFilesForPost(this.props.post.id);
}
}
handleImageClick(indexClicked) {
this.setState({showPreviewModal: true, startImgIndex: indexClicked});
}
......
......@@ -68,4 +68,32 @@ describe('components/FileAttachmentList', () => {
expect(wrapper.state('showPreviewModal')).toEqual(false);
});
test('should call for getMissingFilesForPost when metadata does not exist', () => {
const newPost = {...post, file_ids: ['file_id_1']};
shallow(
<FileAttachmentList
{...baseProps}
post={newPost}
fileInfos={[{id: 'file_id_1', name: 'image_1.png', extension: 'png', create_at: 1}]}
fileCount={1}
/>
);
expect(baseProps.actions.getMissingFilesForPost).toHaveBeenCalledWith(post.id);
});
test('should not call for getMissingFilesForPost when metadata exists', () => {
const newPost = {...post, file_ids: ['file_id_1'], metadata: {}};
shallow(
<FileAttachmentList
{...baseProps}
post={newPost}
fileInfos={[{id: 'file_id_1', name: 'image_1.png', extension: 'png', create_at: 1}]}
fileCount={1}
/>
);
expect(baseProps.actions.getMissingFilesForPost).not.toHaveBeenCalled();
});
});
......@@ -2,6 +2,8 @@
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getMissingFilesForPost} from 'mattermost-redux/actions/files';
import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files';
import {getCurrentLocale} from 'selectors/i18n';
......@@ -32,4 +34,12 @@ function makeMapStateToProps() {
};
}
export default connect(makeMapStateToProps)(FileAttachmentList);
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
getMissingFilesForPost,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(FileAttachmentList);
......@@ -4,6 +4,8 @@
import PropTypes from 'prop-types';
import React from 'react';
const WAIT_FOR_HEIGHT_TIMEOUT = 100;
export default class MarkdownImage extends React.PureComponent {
static propTypes = {
......@@ -18,15 +20,64 @@ export default class MarkdownImage extends React.PureComponent {
onHeightReceived: PropTypes.func,
}
constructor(props) {
super(props);
if (!props.dimensions) {
this.heightTimeout = 0;
}
}
componentDidMount() {
if (!this.props.dimensions) {
this.waitForHeight();
}
}
componentDidUpdate(prevProps) {
if (this.props.href !== prevProps.href) {
this.waitForHeight();
}
}
componentWillUnmount() {
this.stopWaitingForHeight();
}
waitForHeight = () => {
if (this.refs.image && this.refs.image.height) {
if (this.props.onHeightReceived) {
this.props.onHeightReceived(this.refs.image.height);
}
this.heightTimeout = 0;
} else {
this.heightTimeout = setTimeout(this.waitForHeight, WAIT_FOR_HEIGHT_TIMEOUT);
}
}
stopWaitingForHeight = () => {
if (this.heightTimeout !== 0) {
clearTimeout(this.heightTimeout);
this.heightTimeout = 0;
return true;
}
return false;
}
handleLoad = () => {
const wasWaiting = this.stopWaitingForHeight();
// image is loaded but still havent recived new post webscoket event for metadata
// so meanwhile correct manually
if (!this.props.dimensions && this.props.onHeightReceived) {
if ((wasWaiting || !this.props.dimensions) && this.props.onHeightReceived) {
this.props.onHeightReceived(this.refs.image.height);
}
};
handleError = () => {
this.stopWaitingForHeight();
};
render() {
const props = {...this.props};
Reflect.deleteProperty(props, 'onHeightReceived');
......@@ -37,6 +88,7 @@ export default class MarkdownImage extends React.PureComponent {
ref='image'
{...props}
onLoad={this.handleLoad}
onError={this.handleError}
style={{
height: this.props.dimensions ? this.props.dimensions.height : 'initial',
}}
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/CommentedOnFilesMessage Should call snapshot when no files and call getFilesForPost 1`] = `""`;
exports[`components/CommentedOnFilesMessage should match snapshot for multiple files 1`] = `
<span>
image_3.png
<FormattedMessage
defaultMessage=" plus {count, number} other {count, plural, one {file} other {files}}"
id="post_body.plusMore"
values={
Object {
"count": 2,
}
}
/>
</span>
`;
exports[`components/CommentedOnFilesMessage should match snapshot for single file 1`] = `
<span>
image_1.png
</span>
`;
......@@ -17,6 +17,19 @@ export default class CommentedOnFilesMessage extends React.PureComponent {
* An array of file metadata for the parent post
*/
fileInfos: PropTypes.arrayOf(PropTypes.object),
actions: PropTypes.shape({
/*
* Function to get file metadata for a post
*/
getFilesForPost: PropTypes.func.isRequired,
}).isRequired,
}
componentDidMount() {
if (!this.props.fileInfos || this.props.fileInfos.length === 0) {
this.props.actions.getFilesForPost(this.props.parentPostId);
}
}
render() {
......
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import CommentedOnFilesMessage from './commented_on_files_message.jsx';
describe('components/CommentedOnFilesMessage', () => {
const parentPostId = 'parentPostId';
const actions = {getFilesForPost: jest.fn()};
const baseProps = {
parentPostId,
actions,
};
test('Should call snapshot when no files and call getFilesForPost', () => {
const wrapper = shallow(
<CommentedOnFilesMessage {...baseProps}/>
);
expect(wrapper).toMatchSnapshot();
expect(actions.getFilesForPost).toHaveBeenCalledTimes(1);
});
test('should match snapshot for single file', () => {
const props = {
...baseProps,
fileInfos: [{id: 'file_id_1', name: 'image_1.png', extension: 'png', create_at: 1}],
};
const wrapper = shallow(
<CommentedOnFilesMessage {...props}/>
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot for multiple files', () => {
const fileInfos = [
{id: 'file_id_3', name: 'image_3.png', extension: 'png', create_at: 3},
{id: 'file_id_2', name: 'image_2.png', extension: 'png', create_at: 2},
{id: 'file_id_1', name: 'image_1.png', extension: 'png', create_at: 1},
];
const props = {
...baseProps,
fileInfos,
};
const wrapper = shallow(
<CommentedOnFilesMessage {...props}/>
);
expect(wrapper).toMatchSnapshot();
});
});
......@@ -2,6 +2,8 @@
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getFilesForPost} from 'mattermost-redux/actions/files';
import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files';
import CommentedOnFilesMessage from './commented_on_files_message.jsx';
......@@ -21,4 +23,12 @@ function makeMapStateToProps() {
};
}
export default connect(makeMapStateToProps)(CommentedOnFilesMessage);
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
getFilesForPost,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(CommentedOnFilesMessage);
......@@ -3,6 +3,7 @@
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getOpenGraphMetadata} from 'mattermost-redux/actions/posts';
import {getOpenGraphMetadataForUrl} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
......@@ -21,6 +22,7 @@ function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
editPost,
getOpenGraphMetadata,
}, dispatch),
};
}
......
......@@ -55,41 +55,67 @@ export default class PostAttachmentOpenGraph extends React.PureComponent {
actions: PropTypes.shape({
editPost: PropTypes.func.isRequired,
/**
* The function to get open graph data for a link
*/
getOpenGraphMetadata: PropTypes.func.isRequired,
}).isRequired,
}
constructor(props) {
super(props);
this.handleRemovePreview = this.handleRemovePreview.bind(this);
const removePreview = this.isRemovePreview(props.post, props.currentUser);
const imageUrl = this.getBestImageUrl(props.openGraphData);
const {metadata} = props.post;
const hasLargeImage = metadata && metadata.images && metadata.images[imageUrl] && imageUrl ? this.hasLargeImage(metadata.images[imageUrl]) : false;
this.state = {
imageLoaded: Boolean(metadata),
hasLargeImage,
removePreview,
imageUrl,
};
}
componentDidMount() {
if (!this.props.post.metadata) {
this.fetchData(this.props.link);
if (this.state.imageUrl) {
this.loadImage(this.state.imageUrl);
}
}
}
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) {
const removePreview = this.isRemovePreview(nextProps.post, nextProps.currentUser);
this.setState({
removePreview,
});
}
if (!Utils.areObjectsEqual(nextProps.openGraphData, this.props.openGraphData)) {
const imageUrl = this.getBestImageUrl(nextProps.openGraphData);
const {metadata} = nextProps.post;
const hasLargeImage = metadata && metadata.images && metadata.images[imageUrl] && imageUrl ? this.hasLargeImage(metadata.images[imageUrl]) : false;
this.setState({
hasLargeImage,
removePreview,
imageUrl,
});
}
if (nextProps.link !== this.props.link && !nextProps.post.metadata) {
this.fetchData(nextProps.link);
}
}
componentDidUpdate() {
setTimeout(postListScrollChange, 0);
componentDidUpdate(prevProps, prevState) {
if (!this.props.post.metadata && (this.state.imageUrl !== prevState.imageUrl)) {
this.loadImage(this.state.imageUrl);
}
}
fetchData = (url) => {
......@@ -122,8 +148,20 @@ export default class PostAttachmentOpenGraph extends React.PureComponent {
return hasLargeImage;
}
onImageLoad = (image) => {
const hasLargeImage = this.hasLargeImage({width: image.target.naturalWidth, height: image.target.naturalHeight});
this.setState({
hasLargeImage,
imageLoaded: true,
});
postListScrollChange();
}
loadImage(src) {
const img = new Image();
img.onload = this.onImageLoad;
img.src = src;
}
......@@ -152,32 +190,39 @@ export default class PostAttachmentOpenGraph extends React.PureComponent {
imageTag(imageUrl, renderingForLargeImage = false) {
let element = null;
const {metadata} = this.props.post;
if (!metadata) {
return element;
}
if (
imageUrl && renderingForLargeImage === this.state.hasLargeImage &&
(!renderingForLargeImage || (renderingForLargeImage && this.props.isEmbedVisible))
) {
if (renderingForLargeImage) {
const imageDimensions = getFileDimensionsForDisplay(metadata.images && metadata.images[imageUrl], MAX_DIMENSIONS_LARGE_IMAGE);
element = (
<img
className={'attachment__image attachment__image--opengraph large_image'}
src={imageUrl}
{...imageDimensions}
/>
);
if (this.state.imageLoaded) {
const imagesDimensions = metadata && metadata.images && metadata.images[imageUrl];
if (renderingForLargeImage) {
const imageDimensions = getFileDimensionsForDisplay(imagesDimensions, MAX_DIMENSIONS_LARGE_IMAGE);
element = (
<img
className={'attachment__image attachment__image--opengraph large_image'}
src={imageUrl}
{...imageDimensions}
/>
);
} else {
const imageDimensions = getFileDimensionsForDisplay(imagesDimensions, MAX_DIMENSIONS_SMALL_IMAGE);
element = this.wrapInSmallImageContainer(
<img
className={'attachment__image attachment__image--opengraph'}
src={imageUrl}
{...imageDimensions}
/>
);
}
} else if (renderingForLargeImage) {
element = <img className={'attachment__image attachment__image--opengraph loading large_image'}/>;
} else {
const imageDimensions = getFileDimensionsForDisplay(metadata.images && metadata.images[imageUrl], MAX_DIMENSIONS_SMALL_IMAGE);
element = this.wrapInSmallImageContainer(
<img
className={'attachment__image attachment__image--opengraph'}
src={imageUrl}
{...imageDimensions}
/>
<img className={'attachment__image attachment__image--opengraph loading '}/>
);
}
}
......
......@@ -227,7 +227,7 @@ export default class PostBodyAdditionalContent extends React.PureComponent {
}
if (this.isLinkImage(link)) {
const {metadata} = this.props.post;
const dimensions = this.props.post.metadata && this.props.post.metadata.images && this.props.post.metadata.images[link];
return (
<PostImage
channelId={this.props.post.channel_id}
......@@ -235,7 +235,7 @@ export default class PostBodyAdditionalContent extends React.PureComponent {
onLinkLoadError={this.handleLinkLoadError}
onLinkLoaded={this.handleLinkLoaded}
handleImageClick={this.handleImageClick}
dimensions={metadata && metadata.images && metadata.images[link]}
dimensions={dimensions}
/>
);
}
......
......@@ -4,6 +4,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import {postListScrollChange} from 'actions/global_actions';
import LoadingImagePreview from 'components/loading_image_preview';
import * as PostUtils from 'utils/post_utils.jsx';
import {getFileDimensionsForDisplay} from 'utils/file_utils';
......@@ -91,6 +92,7 @@ export default class PostImageEmbed extends React.PureComponent {
errored: false,
});
postListScrollChange();
if (this.props.onLinkLoaded) {
this.props.onLinkLoaded();
}
......@@ -114,6 +116,9 @@ export default class PostImageEmbed extends React.PureComponent {
render() {
const imageDimensions = getFileDimensionsForDisplay(this.props.dimensions, MAX_IMAGE_DIMENSIONS);
if (this.state.errored || !this.state.loaded) {
if (!this.props.dimensions) {
return null;
}
return (
<div style={{...imageDimensions, marginBottom: '13px'}}>
<LoadingImagePreview
......
......@@ -115,6 +115,7 @@ export default class PostList extends React.PureComponent {
this.postListContentRef = React.createRef();
this.scrollAnimationFrame = null;
this.resizeAnimationFrame = null;
this.atBottom = false;
this.state = {
atEnd: false,
......@@ -157,6 +158,7 @@ export default class PostList extends React.PureComponent {
}
componentDidUpdate(prevProps, prevState, snapshot) {
this.previousScrollHeight = snapshot && snapshot.previousScrollHeight;
if (this.props.focusedPostId && this.props.focusedPostId !== prevProps.focusedPostId) {
this.hasScrolledToFocusedPost = false;
this.hasScrolledToNewMessageSeparator = false;
......@@ -165,7 +167,7 @@ export default class PostList extends React.PureComponent {
this.hasScrolled = false;
this.hasScrolledToFocusedPost = false;
this.hasScrolledToNewMessageSeparator = false;
this.atBottom = false;
this.extraPagesLoaded = 0;
this.setState({atEnd: false, isDoingInitialLoad: !this.props.posts, unViewedCount: 0}); // eslint-disable-line react/no-did-update-set-state
......@@ -205,6 +207,7 @@ export default class PostList extends React.PureComponent {
const rect = element.getBoundingClientRect();
const listHeight = postList.clientHeight / 2;
postList.scrollTop = (rect.top - listHeight) + postList.scrollTop;
this.atBottom = this.checkBottom();
} else if (snapshot.previousScrollHeight !== postlistScrollHeight && posts[0].id === prevPosts[0].id) {
postList.scrollTop = snapshot.previousScrollTop + (postlistScrollHeight - snapshot.previousScrollHeight);
}
......@@ -241,6 +244,7 @@ export default class PostList extends React.PureComponent {
}
if (doScrollToBottom) {
this.atBottom = true;
postList.scrollTop = postlistScrollHeight;
return;
}
......@@ -310,6 +314,7 @@ export default class PostList extends React.PureComponent {
}
// Scroll to bottom since we don't have unread posts or we can show every new post in the screen
this.atBottom = true;
postList.scrollTop = postList.scrollHeight;
return true;
}
......@@ -359,7 +364,7 @@ export default class PostList extends React.PureComponent {
this.resizeAnimationFrame = window.requestAnimationFrame(() => {
const postList = this.postListRef.current;
const messageSeparator = this.refs.newMessageSeparator;
const doScrollToBottom = this.checkBottom() || forceScrollToBottom;
const doScrollToBottom = this.atBottom || forceScrollToBottom;
if (postList) {
if (doScrollToBottom) {
......@@ -367,9 +372,10 @@ export default class PostList extends React.PureComponent {
} else if (!this.hasScrolled && messageSeparator) {
const element = ReactDOM.findDOMNode(messageSeparator);
element.scrollIntoView();
this.atBottom = this.checkBottom();
}
this.previousScrollHeight = postList.scrollHeight;
}
this.props.actions.checkAndSetMobileView();
});
}
......@@ -436,6 +442,10 @@ export default class PostList extends React.PureComponent {
this.hasScrolled = this.hasScrolledToNewMessageSeparator || this.hasScrolledToFocusedPost;
const postList = this.postListRef.current;
if (postList.scrollHeight === this.previousScrollHeight) {