// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. const Post = require('./post.jsx'); const UserProfile = require('./user_profile.jsx'); const AsyncClient = require('../utils/async_client.jsx'); const LoadingScreen = require('./loading_screen.jsx'); const PostStore = require('../stores/post_store.jsx'); const ChannelStore = require('../stores/channel_store.jsx'); const UserStore = require('../stores/user_store.jsx'); const SocketStore = require('../stores/socket_store.jsx'); const PreferenceStore = require('../stores/preference_store.jsx'); const Utils = require('../utils/utils.jsx'); const Client = require('../utils/client.jsx'); const Constants = require('../utils/constants.jsx'); const ActionTypes = Constants.ActionTypes; const SocketEvents = Constants.SocketEvents; const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); export default class PostList extends React.Component { constructor(props) { super(props); this.gotMorePosts = false; this.scrolled = false; this.prevScrollTop = 0; this.seenNewMessages = false; this.isUserScroll = true; this.userHasSeenNew = false; this.loadInProgress = false; this.onChange = this.onChange.bind(this); this.onTimeChange = this.onTimeChange.bind(this); this.onSocketChange = this.onSocketChange.bind(this); this.createChannelIntroMessage = this.createChannelIntroMessage.bind(this); this.loadMorePosts = this.loadMorePosts.bind(this); this.loadFirstPosts = this.loadFirstPosts.bind(this); this.activate = this.activate.bind(this); this.deactivate = this.deactivate.bind(this); this.handleResize = this.handleResize.bind(this); this.resizePostList = this.resizePostList.bind(this); this.updateScroll = this.updateScroll.bind(this); const state = this.getStateFromStores(props.channelId); state.numToDisplay = Constants.POST_CHUNK_SIZE; state.isFirstLoadComplete = false; state.windowHeight = Utils.windowHeight(); this.state = state; } getStateFromStores(id) { var postList = PostStore.getPosts(id); if (postList != null) { var deletedPosts = PostStore.getUnseenDeletedPosts(id); if (deletedPosts && Object.keys(deletedPosts).length > 0) { for (var pid in deletedPosts) { if (deletedPosts.hasOwnProperty(pid)) { postList.posts[pid] = deletedPosts[pid]; postList.order.unshift(pid); } } postList.order.sort((a, b) => { if (postList.posts[a].create_at > postList.posts[b].create_at) { return -1; } if (postList.posts[a].create_at < postList.posts[b].create_at) { return 1; } return 0; }); } var pendingPostList = PostStore.getPendingPosts(id); if (pendingPostList) { postList.order = pendingPostList.order.concat(postList.order); for (var ppid in pendingPostList.posts) { if (pendingPostList.posts.hasOwnProperty(ppid)) { postList.posts[ppid] = pendingPostList.posts[ppid]; } } } } return { postList }; } componentDidMount() { window.onload = () => this.scrollToBottom(); if (this.props.isActive) { this.activate(); this.loadFirstPosts(this.props.channelId); } } componentWillUnmount() { this.deactivate(); } activate() { this.gotMorePosts = false; this.scrolled = false; this.prevScrollTop = 0; this.seenNewMessages = false; this.isUserScroll = true; this.userHasSeenNew = false; PostStore.clearUnseenDeletedPosts(this.props.channelId); PostStore.addChangeListener(this.onChange); UserStore.addStatusesChangeListener(this.onTimeChange); PreferenceStore.addChangeListener(this.onTimeChange); SocketStore.addChangeListener(this.onSocketChange); const postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); window.addEventListener('resize', this.handleResize); postHolder.on('scroll', () => { const position = postHolder.scrollTop() + postHolder.height() + 14; const bottom = postHolder[0].scrollHeight; if (position >= bottom) { this.scrolled = false; } else { this.scrolled = true; } if (this.isUserScroll) { this.userHasSeenNew = true; } this.isUserScroll = true; $('.top-visible-post').removeClass('top-visible-post'); $(ReactDOM.findDOMNode(this.refs.postlistcontent)).children().each(function select() { if ($(this).position().top + $(this).height() / 2 > 0) { $(this).addClass('top-visible-post'); return false; } }); }); $('.post-list__content div .post').removeClass('post--last'); $('.post-list__content div:last-child .post').addClass('post--last'); if (!this.state.isFirstLoadComplete) { this.loadFirstPosts(this.props.channelId); } this.resizePostList(); this.onChange(); this.scrollToBottom(); } deactivate() { PostStore.removeChangeListener(this.onChange); UserStore.removeStatusesChangeListener(this.onTimeChange); SocketStore.removeChangeListener(this.onSocketChange); PreferenceStore.removeChangeListener(this.onTimeChange); $('body').off('click.userpopover'); window.removeEventListener('resize', this.handleResize); var postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); postHolder.off('scroll'); } componentDidUpdate(prevProps, prevState) { if (!this.props.isActive) { return; } if (prevState.windowHeight !== this.state.windowHeight) { this.resizePostList(); if (!this.scrolled) { this.scrollToBottom(); } } $('.post-list__content div .post').removeClass('post--last'); $('.post-list__content div:last-child .post').addClass('post--last'); if (this.state.postList == null || prevState.postList == null) { this.scrollToBottom(); return; } var order = this.state.postList.order || []; var posts = this.state.postList.posts || {}; var oldOrder = prevState.postList.order || []; var oldPosts = prevState.postList.posts || {}; var userId = UserStore.getCurrentId(); var firstPost = posts[order[0]] || {}; var isNewPost = oldOrder.indexOf(order[0]) === -1; if (this.props.isActive && !prevProps.isActive) { this.scrollToBottom(); } else if (oldOrder.length === 0) { this.scrollToBottom(); // the user is scrolled to the bottom } else if (!this.scrolled) { this.scrollToBottom(); // there's a new post and // it's by the user (and not from their webhook) and not a comment } else if (isNewPost && userId === firstPost.user_id && !firstPost.props.from_webhook && !Utils.isComment(firstPost)) { this.scrollToBottom(true); // the user clicked 'load more messages' } else if (this.gotMorePosts && oldOrder.length > 0) { let index; if (prevState.numToDisplay >= oldOrder.length) { index = oldOrder.length - 1; } else { index = prevState.numToDisplay; } const lastPost = oldPosts[oldOrder[index]]; $('#post_' + lastPost.id)[0].scrollIntoView(); this.gotMorePosts = false; } else { this.scrollTo(this.prevScrollTop); } } componentWillUpdate() { var postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); this.prevScrollTop = postHolder.scrollTop(); } componentWillReceiveProps(nextProps) { if (nextProps.isActive === true && this.props.isActive === false) { this.activate(); } else if (nextProps.isActive === false && this.props.isActive === true) { this.deactivate(); } } updateScroll() { if (!this.scrolled) { this.scrollToBottom(); } } handleResize() { this.setState({ windowHeight: Utils.windowHeight() }); } resizePostList() { const postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); if ($('#create_post').length > 0) { const height = this.state.windowHeight - $('#create_post').height() - $('#error_bar').outerHeight() - 50; postHolder.css('height', height + 'px'); } } scrollTo(val) { this.isUserScroll = false; var postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); postHolder[0].scrollTop = val; } scrollToBottom(force) { this.isUserScroll = false; var postHolder = $(ReactDOM.findDOMNode(this.refs.postlist)); if ($('#new_message_' + this.props.channelId)[0] && !this.userHasSeenNew && !force) { $('#new_message_' + this.props.channelId)[0].scrollIntoView(); } else { postHolder.addClass('hide-scroll'); postHolder[0].scrollTop = postHolder[0].scrollHeight; postHolder.removeClass('hide-scroll'); } } loadFirstPosts(id) { if (this.loadInProgress) { return; } if (this.props.channelId == null) { return; } this.loadInProgress = true; Client.getPosts( id, PostStore.getLatestUpdate(id), () => { this.loadInProgress = false; this.setState({isFirstLoadComplete: true}); }, () => { this.loadInProgress = false; this.setState({isFirstLoadComplete: true}); } ); } onChange() { var newState = this.getStateFromStores(this.props.channelId); if (!Utils.areStatesEqual(newState.postList, this.state.postList)) { this.setState(newState); } } onSocketChange(msg) { if (msg.action === SocketEvents.POST_DELETED) { var activeRoot = $(document.activeElement).closest('.comment-create-body')[0]; var activeRootPostId = ''; if (activeRoot && activeRoot.id.length > 0) { activeRootPostId = activeRoot.id; } if (activeRootPostId === msg.props.post_id && UserStore.getCurrentId() !== msg.user_id) { $('#post_deleted').modal('show'); } } } onTimeChange() { if (!this.state.postList) { return; } for (var id in this.state.postList.posts) { if (!this.refs[id]) { continue; } this.refs[id].forceUpdateInfo(); } } createDMIntroMessage(channel) { var teammate = Utils.getDirectTeammate(channel.id); if (teammate) { var teammateName = teammate.username; if (teammate.nickname.length > 0) { teammateName = teammate.nickname; } return (

{'This is the start of your direct message history with ' + teammateName + '.'}
{'Direct messages and files shared here are not shown to people outside this area.'}

{'Set a description'}
); } return (

{'This is the start of your direct message history with this teammate. Direct messages and files shared here are not shown to people outside this area.'}

); } createChannelIntroMessage(channel) { if (channel.type === 'D') { return this.createDMIntroMessage(channel); } else if (ChannelStore.isDefault(channel)) { return this.createDefaultIntroMessage(channel); } else if (channel.name === Constants.OFFTOPIC_CHANNEL) { return this.createOffTopicIntroMessage(channel); } else if (channel.type === 'O' || channel.type === 'P') { return this.createStandardIntroMessage(channel); } } createDefaultIntroMessage(channel) { return (

{'Beginning of ' + channel.display_name}

{'Welcome to ' + channel.display_name + '!'}

{'This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.'}

{'To create a new channel or join an existing one, go to the Left Sidebar under “Channels” and click “More…”.'}

); } createOffTopicIntroMessage(channel) { return (

{'Beginning of ' + channel.display_name}

{'This is the start of ' + channel.display_name + ', a channel for non-work-related conversations.'}

{'Set a description'} {'Invite others to this channel'}
); } getChannelCreator(channel) { if (channel.creator_id.length > 0) { var creator = UserStore.getProfile(channel.creator_id); if (creator) { return creator.username; } } var members = ChannelStore.getExtraInfo(channel.id).members; for (var i = 0; i < members.length; i++) { if (Utils.isAdmin(members[i].roles)) { return members[i].username; } } } createStandardIntroMessage(channel) { var uiName = channel.display_name; var creatorName = ''; var uiType; var memberMessage; if (channel.type === 'P') { uiType = 'private group'; memberMessage = ' Only invited members can see this private group.'; } else { uiType = 'channel'; memberMessage = ' Any member can join and read this channel.'; } var createMessage; if (creatorName === '') { createMessage = 'This is the start of the ' + uiName + ' ' + uiType + ', created on ' + Utils.displayDate(channel.create_at) + '.'; } else { createMessage = (This is the start of the {uiName} {uiType}, created by {creatorName} on {Utils.displayDate(channel.create_at)}); } return (

{'Beginning of ' + uiName}

{createMessage} {memberMessage}

{'Set a description'} {'Invite others to this ' + uiType}
); } createPosts(posts, order) { var postCtls = []; var previousPostDay = new Date(0); var userId = UserStore.getCurrentId(); var renderedLastViewed = false; var lastViewed = Number.MAX_VALUE; if (ChannelStore.getMember(this.props.channelId) != null) { lastViewed = ChannelStore.getMember(this.props.channelId).last_viewed_at; } var numToDisplay = this.state.numToDisplay; if (order.length - 1 < numToDisplay) { numToDisplay = order.length - 1; } for (var i = numToDisplay; i >= 0; i--) { var post = posts[order[i]]; var parentPost = posts[post.parent_id]; var sameUser = false; var sameRoot = false; var hideProfilePic = false; var prevPost = posts[order[i + 1]]; if (prevPost) { sameUser = prevPost.user_id === post.user_id && post.create_at - prevPost.create_at <= 1000 * 60 * 5; sameRoot = Utils.isComment(post) && (prevPost.id === post.root_id || prevPost.root_id === post.root_id); // 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 current post is not from a webhook // and the previous post is not from a webhook if ((prevPost.user_id === post.user_id) && !Utils.isComment(prevPost) && !Utils.isComment(post) && (!post.props || !post.props.from_webhook) && (!prevPost.props || !prevPost.props.from_webhook)) { 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 var isLastComment = Utils.isComment(post) && (i === 0 || posts[order[i - 1]].root_id !== post.root_id); var postCtl = ( ); const currentPostDay = Utils.getDateForUnixTicks(post.create_at); if (currentPostDay.toDateString() !== previousPostDay.toDateString()) { postCtls.push(

{currentPostDay.toDateString()}
); } if (post.user_id !== userId && post.create_at > lastViewed && !renderedLastViewed) { renderedLastViewed = true; // Temporary fix to solve ie11 rendering issue let newSeparatorId = ''; if (!Utils.isBrowserIE()) { newSeparatorId = 'new_message_' + this.props.channelId; } postCtls.push(

{'New Messages'}
); } postCtls.push(postCtl); previousPostDay = currentPostDay; } return postCtls; } loadMorePosts() { if (this.state.postList == null) { return; } var posts = this.state.postList.posts; var order = this.state.postList.order; var channelId = this.props.channelId; $(ReactDOM.findDOMNode(this.refs.loadmore)).text('Retrieving more messages...'); Client.getPostsPage( channelId, order.length, Constants.POST_CHUNK_SIZE, function success(data) { $(ReactDOM.findDOMNode(this.refs.loadmore)).text('Load more messages'); this.gotMorePosts = true; this.setState({numToDisplay: this.state.numToDisplay + Constants.POST_CHUNK_SIZE}); if (!data) { return; } if (data.order.length === 0) { return; } var postList = {}; postList.posts = $.extend(posts, data.posts); postList.order = order.concat(data.order); AppDispatcher.handleServerAction({ type: ActionTypes.RECIEVED_POSTS, id: channelId, post_list: postList }); Client.getProfiles(); }.bind(this), function fail(err) { $(ReactDOM.findDOMNode(this.refs.loadmore)).text('Load more messages'); AsyncClient.dispatchError(err, 'getPosts'); }.bind(this) ); } render() { var order = []; var posts; var channel = ChannelStore.get(this.props.channelId); if (this.state.postList != null) { posts = this.state.postList.posts; order = this.state.postList.order; } var moreMessages =

{'Beginning of Channel'}

; if (channel != null) { if (order.length >= this.state.numToDisplay) { moreMessages = ( {'Load more messages'} ); } else { moreMessages = this.createChannelIntroMessage(channel); } } var postCtls = []; if (posts && this.state.isFirstLoadComplete) { postCtls = this.createPosts(posts, order); } else { postCtls.push( ); } var activeClass = ''; if (!this.props.isActive) { activeClass = 'inactive'; } return (
{moreMessages} {postCtls}
); } } PostList.defaultProps = { isActive: false, channelId: null }; PostList.propTypes = { isActive: React.PropTypes.bool, channelId: React.PropTypes.string };