diff options
author | Christopher Speller <crspeller@gmail.com> | 2016-03-16 18:16:11 -0400 |
---|---|---|
committer | Christopher Speller <crspeller@gmail.com> | 2016-03-16 18:16:11 -0400 |
commit | 4a4859351a4cd277f94d3faa804daaad0733b270 (patch) | |
tree | 4e7f83d3e2564b9b89d669e9f7905ff11768b11a /webapp/components/posts_view.jsx | |
parent | 29fe6a3d13c9c7aa490fffebbe5d1b5fdf1e3090 (diff) | |
parent | 12896bd23eeba79884245c1c29fdc568cf21a7fa (diff) | |
download | chat-4a4859351a4cd277f94d3faa804daaad0733b270.tar.gz chat-4a4859351a4cd277f94d3faa804daaad0733b270.tar.bz2 chat-4a4859351a4cd277f94d3faa804daaad0733b270.zip |
Merge pull request #2453 from mattermost/plt-2340
PLT-2340 Converting to Webpack. Stage 1.
Diffstat (limited to 'webapp/components/posts_view.jsx')
-rw-r--r-- | webapp/components/posts_view.jsx | 608 |
1 files changed, 608 insertions, 0 deletions
diff --git a/webapp/components/posts_view.jsx b/webapp/components/posts_view.jsx new file mode 100644 index 000000000..8b4b0c662 --- /dev/null +++ b/webapp/components/posts_view.jsx @@ -0,0 +1,608 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import ReactDOM from 'react-dom'; +import PreferenceStore from 'stores/preference_store.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; +import * as Utils from 'utils/utils.jsx'; +import Post from './post.jsx'; +import Constants from 'utils/constants.jsx'; +import DelayedAction from 'utils/delayed_action.jsx'; + +import {FormattedDate, FormattedMessage} from 'react-intl'; + +const Preferences = Constants.Preferences; + +import React from 'react'; + +export default class PostsView extends React.Component { + constructor(props) { + super(props); + + this.updateState = this.updateState.bind(this); + this.handleScroll = this.handleScroll.bind(this); + this.handleScrollStop = this.handleScrollStop.bind(this); + this.isAtBottom = this.isAtBottom.bind(this); + this.loadMorePostsTop = this.loadMorePostsTop.bind(this); + this.loadMorePostsBottom = this.loadMorePostsBottom.bind(this); + this.createPosts = this.createPosts.bind(this); + this.updateScrolling = this.updateScrolling.bind(this); + this.handleResize = this.handleResize.bind(this); + this.scrollToBottom = this.scrollToBottom.bind(this); + this.scrollToBottomAnimated = this.scrollToBottomAnimated.bind(this); + + this.jumpToPostNode = null; + this.wasAtBottom = true; + this.scrollHeight = 0; + + this.scrollStopAction = new DelayedAction(this.handleScrollStop); + + this.state = { + displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'), + isScrolling: false, + topPostId: null + }; + } + static get SCROLL_TYPE_FREE() { + return 1; + } + static get SCROLL_TYPE_BOTTOM() { + return 2; + } + static get SCROLL_TYPE_SIDEBAR_OPEN() { + return 3; + } + static get SCROLL_TYPE_NEW_MESSAGE() { + return 4; + } + static get SCROLL_TYPE_POST() { + return 5; + } + updateState() { + this.setState({displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false')}); + } + isAtBottom() { + // consider the view to be at the bottom if it's within this many pixels of the bottom + const atBottomMargin = 10; + + return this.refs.postlist.clientHeight + this.refs.postlist.scrollTop >= this.refs.postlist.scrollHeight - atBottomMargin; + } + handleScroll() { + // HACK FOR RHS -- REMOVE WHEN RHS DIES + const childNodes = this.refs.postlistcontent.childNodes; + for (let i = 0; i < childNodes.length; i++) { + // If the node is 1/3 down the page + if (childNodes[i].offsetTop > (this.refs.postlist.scrollTop + (this.refs.postlist.offsetHeight / 3))) { + this.jumpToPostNode = childNodes[i]; + break; + } + } + this.wasAtBottom = this.isAtBottom(); + if (!this.jumpToPostNode && childNodes.length > 0) { + this.jumpToPostNode = childNodes[childNodes.length - 1]; + } + + // --- -------- + + this.props.postViewScrolled(this.isAtBottom()); + this.prevScrollHeight = this.refs.postlist.scrollHeight; + this.prevOffsetTop = this.jumpToPostNode.offsetTop; + + this.updateFloatingTimestamp(); + + if (!this.state.isScrolling) { + this.setState({ + isScrolling: true + }); + } + + this.scrollStopAction.fireAfter(2000); + } + handleScrollStop() { + this.setState({ + isScrolling: false + }); + } + updateFloatingTimestamp() { + // skip this in non-mobile view since that's when the timestamp is visible + if ($(window).width() > 768) { + return; + } + + if (this.props.postList) { + // iterate through posts starting at the bottom since users are more likely to be viewing newer posts + for (let i = 0; i < this.props.postList.order.length; i++) { + const id = this.props.postList.order[i]; + const element = ReactDOM.findDOMNode(this.refs[id]); + + if (!element || element.offsetTop + element.clientHeight <= this.refs.postlist.scrollTop) { + // this post is off the top of the screen so the last one is at the top of the screen + let topPostId; + + if (i > 0) { + topPostId = this.props.postList.order[i - 1]; + } else { + // the first post we look at should always be on the screen, but handle that case anyway + topPostId = id; + } + + if (topPostId !== this.state.topPostId) { + this.setState({ + topPostId + }); + } + + break; + } + } + } + } + loadMorePostsTop() { + this.props.loadMorePostsTopClicked(); + } + loadMorePostsBottom() { + this.props.loadMorePostsBottomClicked(); + } + createPosts(posts, order) { + const postCtls = []; + let previousPostDay = new Date(0); + const userId = this.props.currentUser.id; + const profiles = this.props.profiles || {}; + + let renderedLastViewed = false; + + for (let i = order.length - 1; i >= 0; i--) { + const post = posts[order[i]]; + const parentPost = posts[post.parent_id]; + const prevPost = posts[order[i + 1]]; + const postUserId = Utils.isSystemMessage(post) ? '' : post.user_id; + + // If the post is a comment whose parent has been deleted, don't add it to the list. + if (parentPost && parentPost.state === Constants.POST_DELETED) { + continue; + } + + let sameUser = false; + let sameRoot = false; + let hideProfilePic = false; + + if (prevPost) { + const postIsComment = Utils.isComment(post); + const prevPostIsComment = Utils.isComment(prevPost); + const postFromWebhook = Boolean(post.props && post.props.from_webhook); + const prevPostFromWebhook = Boolean(prevPost.props && prevPost.props.from_webhook); + const prevPostUserId = Utils.isSystemMessage(prevPost) ? '' : prevPost.user_id; + let prevWebhookName = ''; + if (prevPost.props && prevPost.props.override_username) { + prevWebhookName = prevPost.props.override_username; + } + let curWebhookName = ''; + if (post.props && post.props.override_username) { + curWebhookName = post.props.override_username; + } + + // consider posts from the same user if: + // the previous post was made by the same user as the current post, + // the previous post was made within 5 minutes of the current post, + // the previous post and current post are both from webhooks or both not, + // the previous post and current post have the same webhook usernames + if (prevPostUserId === postUserId && + post.create_at - prevPost.create_at <= 1000 * 60 * 5 && + postFromWebhook === prevPostFromWebhook && + prevWebhookName === curWebhookName) { + sameUser = true; + } + + // consider posts from the same root if: + // the current post is a comment, + // the current post has the same root as the previous post + if (postIsComment && (prevPost.id === post.root_id || prevPost.root_id === post.root_id)) { + sameRoot = true; + } + + // consider posts from the same root if: + // the current post is not a comment, + // the previous post is not a comment, + // the previous post is from the same user + if (!postIsComment && !prevPostIsComment && sameUser) { + sameRoot = true; + } + + // hide the profile pic if: + // the previous post was made by the same user as the current post, + // the previous post is not a comment, + // the current post is not a comment, + // the previous post and current post are both from webhooks or both not, + // the previous post and current post have the same webhook usernames + if (prevPostUserId === postUserId && + !prevPostIsComment && + !postIsComment && + postFromWebhook === prevPostFromWebhook && + prevWebhookName === curWebhookName) { + hideProfilePic = true; + } + } + + // check if it's the last comment in a consecutive string of comments on the same post + // it is the last comment if it is last post in the channel or the next post has a different root post + const isLastComment = Utils.isComment(post) && (i === 0 || posts[order[i - 1]].root_id !== post.root_id); + + const keyPrefix = post.id ? post.id : i; + + const shouldHighlight = this.props.postsToHighlight && this.props.postsToHighlight.hasOwnProperty(post.id); + + let profile; + if (this.props.currentUser.id === post.user_id) { + profile = this.props.currentUser; + } else { + profile = profiles[post.user_id]; + } + + const postCtl = ( + <Post + key={keyPrefix + 'postKey'} + ref={post.id} + sameUser={sameUser} + sameRoot={sameRoot} + post={post} + parentPost={parentPost} + posts={posts} + hideProfilePic={hideProfilePic} + isLastComment={isLastComment} + shouldHighlight={shouldHighlight} + onClick={() => GlobalActions.emitPostFocusEvent(post.id)} //eslint-disable-line no-loop-func + displayNameType={this.state.displayNameType} + user={profile} + currentUser={this.props.currentUser} + /> + ); + + const currentPostDay = Utils.getDateForUnixTicks(post.create_at); + if (currentPostDay.toDateString() !== previousPostDay.toDateString()) { + postCtls.push( + <div + key={currentPostDay.toDateString()} + className='date-separator' + > + <hr className='separator__hr'/> + <div className='separator__text'> + <FormattedDate + value={currentPostDay} + weekday='short' + month='short' + day='2-digit' + year='numeric' + /> + </div> + </div> + ); + } + + if (postUserId !== userId && + this.props.messageSeparatorTime !== 0 && + post.create_at > this.props.messageSeparatorTime && + !renderedLastViewed) { + renderedLastViewed = true; + + // Temporary fix to solve ie11 rendering issue + let newSeparatorId = ''; + if (!Utils.isBrowserIE()) { + newSeparatorId = 'new_message_' + post.id; + } + postCtls.push( + <div + id={newSeparatorId} + key='unviewed' + ref='newMessageSeparator' + className='new-separator' + > + <hr + className='separator__hr' + /> + <div className='separator__text'> + <FormattedMessage + id='posts_view.newMsg' + defaultMessage='New Messages' + /> + </div> + </div> + ); + } + postCtls.push(postCtl); + previousPostDay = currentPostDay; + } + + return postCtls; + } + updateScrolling() { + if (this.props.scrollType === PostsView.SCROLL_TYPE_BOTTOM) { + this.scrollToBottom(); + } else if (this.props.scrollType === PostsView.SCROLL_TYPE_NEW_MESSAGE) { + window.requestAnimationFrame(() => { + // If separator exists scroll to it. Otherwise scroll to bottom. + if (this.refs.newMessageSeparator) { + var objDiv = this.refs.postlist; + objDiv.scrollTop = this.refs.newMessageSeparator.offsetTop; //scrolls node to top of Div + } else if (this.refs.postlist) { + this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; + } + }); + } else if (this.props.scrollType === PostsView.SCROLL_TYPE_POST && this.props.scrollPostId) { + window.requestAnimationFrame(() => { + const postNode = ReactDOM.findDOMNode(this.refs[this.props.scrollPostId]); + if (postNode == null) { + return; + } + postNode.scrollIntoView(); + if (this.refs.postlist.scrollTop === postNode.offsetTop) { + this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / 3); + } else { + this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / 3) + (this.refs.postlist.scrollTop - postNode.offsetTop); + } + }); + } else if (this.props.scrollType === PostsView.SCROLL_TYPE_SIDEBAR_OPEN) { + // If we are at the bottom then stay there + if (this.wasAtBottom) { + this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; + } else { + window.requestAnimationFrame(() => { + this.jumpToPostNode.scrollIntoView(); + if (this.refs.postlist.scrollTop === this.jumpToPostNode.offsetTop) { + this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / 3); + } else { + this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / 3) + (this.refs.postlist.scrollTop - this.jumpToPostNode.offsetTop); + } + }); + } + } else if (this.refs.postlist.scrollHeight !== this.prevScrollHeight) { + window.requestAnimationFrame(() => { + // Only need to jump if we added posts to the top. + if (this.jumpToPostNode && (this.jumpToPostNode.offsetTop !== this.prevOffsetTop)) { + this.refs.postlist.scrollTop += (this.refs.postlist.scrollHeight - this.prevScrollHeight); + } + }); + } + } + handleResize() { + this.updateScrolling(); + } + scrollToBottom() { + window.requestAnimationFrame(() => { + this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; + }); + } + scrollToBottomAnimated() { + var postList = $(this.refs.postlist); + postList.animate({scrollTop: this.refs.postlist.scrollHeight}, '500'); + } + componentDidMount() { + if (this.props.postList != null) { + this.updateScrolling(); + } + window.addEventListener('resize', this.handleResize); + } + componentWillUnmount() { + window.removeEventListener('resize', this.handleResize); + } + componentDidUpdate() { + if (this.props.postList != null) { + this.updateScrolling(); + } + } + componentWillReceiveProps(nextProps) { + if (!this.props.isActive && nextProps.isActive) { + this.updateState(); + PreferenceStore.addChangeListener(this.updateState); + } else if (this.props.isActive && !nextProps.isActive) { + PreferenceStore.removeChangeListener(this.updateState); + } + } + shouldComponentUpdate(nextProps, nextState) { + if (this.props.isActive !== nextProps.isActive) { + return true; + } + if (this.props.postList !== nextProps.postList) { + return true; + } + if (this.props.scrollPostId !== nextProps.scrollPostId) { + return true; + } + if (this.props.scrollType !== nextProps.scrollType && nextProps.scrollType !== PostsView.SCROLL_TYPE_FREE) { + return true; + } + if (this.props.messageSeparatorTime !== nextProps.messageSeparatorTime) { + return true; + } + if (!Utils.areObjectsEqual(this.props.postList, nextProps.postList)) { + return true; + } + if (nextState.displayNameType !== this.state.displayNameType) { + return true; + } + if (this.state.topPostId !== nextState.topPostId) { + return true; + } + if (this.state.isScrolling !== nextState.isScrolling) { + return true; + } + if (!Utils.areObjectsEqual(this.props.profiles, nextProps.profiles)) { + return true; + } + + return false; + } + render() { + let posts = []; + let order = []; + let moreMessagesTop; + let moreMessagesBottom; + let postElements; + let activeClass = 'inactive'; + if (this.props.postList != null) { + posts = this.props.postList.posts; + order = this.props.postList.order; + + // Create intro message or top loadmore link + if (this.props.showMoreMessagesTop) { + moreMessagesTop = ( + <a + ref='loadmoretop' + className='more-messages-text theme' + href='#' + onClick={this.loadMorePostsTop} + > + <FormattedMessage + id='posts_view.loadMore' + defaultMessage='Load more messages' + /> + </a> + ); + } else { + moreMessagesTop = this.props.introText; + } + + // Give option to load more posts at bottom if nessisary + if (this.props.showMoreMessagesBottom) { + moreMessagesBottom = ( + <a + ref='loadmorebottom' + className='more-messages-text theme' + href='#' + onClick={this.loadMorePostsBottom} + > + <FormattedMessage id='posts_view.loadMore'/> + </a> + ); + } else { + moreMessagesBottom = null; + } + + // Create post elements + postElements = this.createPosts(posts, order); + + // Show ourselves if we are marked active + if (this.props.isActive) { + activeClass = ''; + } + } + + let topPost = null; + if (this.state.topPostId) { + topPost = this.props.postList.posts[this.state.topPostId]; + } + + return ( + <div className={activeClass}> + <FloatingTimestamp + isScrolling={this.state.isScrolling} + post={topPost} + /> + <ScrollToBottomArrows + isScrolling={this.state.isScrolling} + atBottom={this.wasAtBottom} + onClick={this.scrollToBottomAnimated} + /> + <div + ref='postlist' + className='post-list-holder-by-time' + onScroll={this.handleScroll} + > + <div className='post-list__table'> + <div + ref='postlistcontent' + className='post-list__content' + > + {moreMessagesTop} + {postElements} + {moreMessagesBottom} + </div> + </div> + </div> + </div> + ); + } +} +PostsView.defaultProps = { +}; + +PostsView.propTypes = { + isActive: React.PropTypes.bool, + postList: React.PropTypes.object, + profiles: React.PropTypes.object.isRequired, + scrollPostId: React.PropTypes.string, + scrollType: React.PropTypes.number, + postViewScrolled: React.PropTypes.func.isRequired, + loadMorePostsTopClicked: React.PropTypes.func.isRequired, + loadMorePostsBottomClicked: React.PropTypes.func.isRequired, + showMoreMessagesTop: React.PropTypes.bool, + showMoreMessagesBottom: React.PropTypes.bool, + introText: React.PropTypes.element, + messageSeparatorTime: React.PropTypes.number, + postsToHighlight: React.PropTypes.object, + currentUser: React.PropTypes.object.isRequired +}; + +function FloatingTimestamp({isScrolling, post}) { + // only show on mobile + if ($(window).width() > 768) { + return <noscript/>; + } + + if (!post) { + return <noscript/>; + } + + const dateString = ( + <FormattedDate + value={post.create_at} + weekday='short' + day='2-digit' + month='short' + year='numeric' + /> + ); + + let className = 'post-list__timestamp'; + if (isScrolling) { + className += ' scrolling'; + } + + return ( + <div className={className}> + <span>{dateString}</span> + </div> + ); +} + +FloatingTimestamp.propTypes = { + isScrolling: React.PropTypes.bool.isRequired, + post: React.PropTypes.object +}; + +function ScrollToBottomArrows({isScrolling, atBottom, onClick}) { + // only show on mobile + if ($(window).width() > 768) { + return <noscript/>; + } + + let className = 'post-list__arrows'; + if (isScrolling && !atBottom) { + className += ' scrolling'; + } + + return ( + <div + className={className} + onClick={onClick} + > + <span dangerouslySetInnerHTML={{__html: Constants.SCROLL_BOTTOM_ICON}}/> + </div> + ); +} + +ScrollToBottomArrows.propTypes = { + isScrolling: React.PropTypes.bool.isRequired, + atBottom: React.PropTypes.bool.isRequired, + onClick: React.PropTypes.func.isRequired +}; |