diff options
author | Joram Wilander <jwawilander@gmail.com> | 2016-05-27 16:01:28 -0400 |
---|---|---|
committer | Christopher Speller <crspeller@gmail.com> | 2016-05-27 16:01:28 -0400 |
commit | 6399a94ce221be3d15e7132654c28cd953075ec6 (patch) | |
tree | 4b1927fdd8374e8bd3cb809ecb720f2689043358 /webapp/components/post_view | |
parent | ca9f348be6bf62fc888df9a710c9af155872528e (diff) | |
download | chat-6399a94ce221be3d15e7132654c28cd953075ec6.tar.gz chat-6399a94ce221be3d15e7132654c28cd953075ec6.tar.bz2 chat-6399a94ce221be3d15e7132654c28cd953075ec6.zip |
PLT-2672 Refactored posts view with caching (#3054)
* Refactored posts view to use view controller design
* Add post view caching
* Required updates after rebase
* Fixed bug where current channel not set yet was causing breakage
Diffstat (limited to 'webapp/components/post_view')
17 files changed, 2999 insertions, 0 deletions
diff --git a/webapp/components/post_view/components/floating_timestamp.jsx b/webapp/components/post_view/components/floating_timestamp.jsx new file mode 100644 index 000000000..8974c62c5 --- /dev/null +++ b/webapp/components/post_view/components/floating_timestamp.jsx @@ -0,0 +1,54 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {FormattedDate} from 'react-intl'; + +import React from 'react'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; + +export default class FloatingTimestamp extends React.Component { + constructor(props) { + super(props); + + this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this); + } + + render() { + if (!this.props.isMobile) { + return <noscript/>; + } + + if (this.props.createAt === 0) { + return <noscript/>; + } + + const dateString = ( + <FormattedDate + value={this.props.createAt} + weekday='short' + day='2-digit' + month='short' + year='numeric' + /> + ); + + let className = 'post-list__timestamp'; + if (this.props.isScrolling) { + className += ' scrolling'; + } + + return ( + <div className={className}> + <div> + <span>{dateString}</span> + </div> + </div> + ); + } +} + +FloatingTimestamp.propTypes = { + isScrolling: React.PropTypes.bool.isRequired, + isMobile: React.PropTypes.bool, + createAt: React.PropTypes.number +}; diff --git a/webapp/components/post_view/components/pending_post_options.jsx b/webapp/components/post_view/components/pending_post_options.jsx new file mode 100644 index 000000000..536ec541c --- /dev/null +++ b/webapp/components/post_view/components/pending_post_options.jsx @@ -0,0 +1,92 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PostStore from 'stores/post_store.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; + +import Client from 'utils/web_client.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; + +import Constants from 'utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; + +import {FormattedMessage} from 'react-intl'; + +import React from 'react'; + +export default class PendingPostOptions extends React.Component { + constructor(props) { + super(props); + this.retryPost = this.retryPost.bind(this); + this.cancelPost = this.cancelPost.bind(this); + this.state = {}; + } + retryPost(e) { + e.preventDefault(); + + var post = this.props.post; + Client.createPost(post, + (data) => { + AsyncClient.getPosts(post.channel_id); + + var channel = ChannelStore.get(post.channel_id); + var member = ChannelStore.getMember(post.channel_id); + member.msg_count = channel.total_msg_count; + member.last_viewed_at = (new Date()).getTime(); + ChannelStore.setChannelMember(member); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POST, + post: data + }); + }, + () => { + post.state = Constants.POST_FAILED; + PostStore.updatePendingPost(post); + this.forceUpdate(); + } + ); + + post.state = Constants.POST_LOADING; + PostStore.updatePendingPost(post); + this.forceUpdate(); + } + cancelPost(e) { + e.preventDefault(); + + var post = this.props.post; + PostStore.removePendingPost(post.channel_id, post.pending_post_id); + this.forceUpdate(); + } + render() { + return (<span className='pending-post-actions'> + <a + className='post-retry' + href='#' + onClick={this.retryPost} + > + <FormattedMessage + id='pending_post_actions.retry' + defaultMessage='Retry' + /> + </a> + {' - '} + <a + className='post-cancel' + href='#' + onClick={this.cancelPost} + > + <FormattedMessage + id='pending_post_actions.cancel' + defaultMessage='Cancel' + /> + </a> + </span>); + } +} + +PendingPostOptions.propTypes = { + post: React.PropTypes.object +}; diff --git a/webapp/components/post_view/components/post.jsx b/webapp/components/post_view/components/post.jsx new file mode 100644 index 000000000..0bf4680fe --- /dev/null +++ b/webapp/components/post_view/components/post.jsx @@ -0,0 +1,216 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PostHeader from './post_header.jsx'; +import PostBody from './post_body.jsx'; + +import Constants from 'utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; + +import * as Utils from 'utils/utils.jsx'; +import * as PostUtils from 'utils/post_utils.jsx'; +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; + +import React from 'react'; + +export default class Post extends React.Component { + constructor(props) { + super(props); + + this.handleCommentClick = this.handleCommentClick.bind(this); + this.forceUpdateInfo = this.forceUpdateInfo.bind(this); + + this.state = {}; + } + handleCommentClick(e) { + e.preventDefault(); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POST_SELECTED, + postId: Utils.getRootId(this.props.post) + }); + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_SEARCH, + results: null + }); + } + forceUpdateInfo() { + this.refs.info.forceUpdate(); + this.refs.header.forceUpdate(); + } + shouldComponentUpdate(nextProps) { + if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) { + return true; + } + + if (nextProps.sameRoot !== this.props.sameRoot) { + return true; + } + + if (nextProps.sameUser !== this.props.sameUser) { + return true; + } + + if (nextProps.displayNameType !== this.props.displayNameType) { + return true; + } + + if (nextProps.commentCount !== this.props.commentCount) { + return true; + } + + if (nextProps.shouldHighlight !== this.props.shouldHighlight) { + return true; + } + + if (nextProps.center !== this.props.center) { + return true; + } + + if (nextProps.compactDisplay !== this.props.compactDisplay) { + return true; + } + + if (!Utils.areObjectsEqual(nextProps.user, this.props.user)) { + return true; + } + + return false; + } + render() { + const post = this.props.post; + const parentPost = this.props.parentPost; + const mattermostLogo = Constants.MATTERMOST_ICON_SVG; + + if (!post.props) { + post.props = {}; + } + + let type = 'Post'; + if (post.root_id && post.root_id.length > 0) { + type = 'Comment'; + } + + const commentCount = this.props.commentCount; + + let rootUser; + if (this.props.sameRoot) { + rootUser = 'same--root'; + } else { + rootUser = 'other--root'; + } + + let postType = ''; + if (type !== 'Post') { + postType = 'post--comment'; + } else if (commentCount > 0) { + postType = 'post--root'; + } + + let currentUserCss = ''; + if (this.props.currentUser.id === post.user_id && !post.props.from_webhook && !PostUtils.isSystemMessage(post)) { + currentUserCss = 'current--user'; + } + + let timestamp = 0; + if (!this.props.user || this.props.user.update_at == null) { + timestamp = this.props.currentUser.update_at; + } else { + timestamp = this.props.user.update_at; + } + + let sameUserClass = ''; + if (this.props.sameUser) { + sameUserClass = 'same--user'; + } + + let shouldHighlightClass = ''; + if (this.props.shouldHighlight) { + shouldHighlightClass = 'post--highlight'; + } + + let systemMessageClass = ''; + if (PostUtils.isSystemMessage(post)) { + systemMessageClass = 'post--system'; + } + + let profilePic = ( + <img + src={PostUtils.getProfilePicSrcForPost(post, timestamp)} + height='36' + width='36' + /> + ); + + if (PostUtils.isSystemMessage(post)) { + profilePic = ( + <span + className='icon' + dangerouslySetInnerHTML={{__html: mattermostLogo}} + /> + ); + } + + let centerClass = ''; + if (this.props.center) { + centerClass = 'center'; + } + + let compactClass = ''; + if (this.props.compactDisplay) { + compactClass = 'post--compact'; + } + + return ( + <div> + <div + id={'post_' + post.id} + className={'post ' + sameUserClass + ' ' + compactClass + ' ' + rootUser + ' ' + postType + ' ' + currentUserCss + ' ' + shouldHighlightClass + ' ' + systemMessageClass} + > + <div className={'post__content ' + centerClass}> + <div className='post__img'>{profilePic}</div> + <div> + <PostHeader + ref='header' + post={post} + sameRoot={this.props.sameRoot} + commentCount={commentCount} + handleCommentClick={this.handleCommentClick} + isLastComment={this.props.isLastComment} + sameUser={this.props.sameUser} + user={this.props.user} + currentUser={this.props.currentUser} + compactDisplay={this.props.compactDisplay} + /> + <PostBody + post={post} + sameRoot={this.props.sameRoot} + parentPost={parentPost} + handleCommentClick={this.handleCommentClick} + compactDisplay={this.props.compactDisplay} + /> + </div> + </div> + </div> + </div> + ); + } +} + +Post.propTypes = { + post: React.PropTypes.object.isRequired, + parentPost: React.PropTypes.object, + user: React.PropTypes.object, + sameUser: React.PropTypes.bool, + sameRoot: React.PropTypes.bool, + hideProfilePic: React.PropTypes.bool, + isLastComment: React.PropTypes.bool, + shouldHighlight: React.PropTypes.bool, + displayNameType: React.PropTypes.string, + hasProfiles: React.PropTypes.bool, + currentUser: React.PropTypes.object.isRequired, + center: React.PropTypes.bool, + compactDisplay: React.PropTypes.bool, + commentCount: React.PropTypes.number +}; diff --git a/webapp/components/post_view/components/post_attachment.jsx b/webapp/components/post_view/components/post_attachment.jsx new file mode 100644 index 000000000..8b5ff91f2 --- /dev/null +++ b/webapp/components/post_view/components/post_attachment.jsx @@ -0,0 +1,324 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import * as TextFormatting from 'utils/text_formatting.jsx'; + +import {intlShape, injectIntl, defineMessages} from 'react-intl'; + +const holders = defineMessages({ + collapse: { + id: 'post_attachment.collapse', + defaultMessage: '▲ collapse text' + }, + more: { + id: 'post_attachment.more', + defaultMessage: '▼ read more' + } +}); + +import React from 'react'; + +class PostAttachment extends React.Component { + constructor(props) { + super(props); + + this.getFieldsTable = this.getFieldsTable.bind(this); + this.getInitState = this.getInitState.bind(this); + this.shouldCollapse = this.shouldCollapse.bind(this); + this.toggleCollapseState = this.toggleCollapseState.bind(this); + } + + componentDidMount() { + $(this.refs.attachment).on('click', '.attachment-link-more', this.toggleCollapseState); + } + + componentWillUnmount() { + $(this.refs.attachment).off('click', '.attachment-link-more', this.toggleCollapseState); + } + + componentWillMount() { + this.setState(this.getInitState()); + } + + getInitState() { + const shouldCollapse = this.shouldCollapse(); + const text = TextFormatting.formatText(this.props.attachment.text || ''); + const uncollapsedText = text + (shouldCollapse ? `<a class="attachment-link-more" href="#">${this.props.intl.formatMessage(holders.collapse)}</a>` : ''); + const collapsedText = shouldCollapse ? this.getCollapsedText() : text; + + return { + shouldCollapse, + collapsedText, + uncollapsedText, + text: shouldCollapse ? collapsedText : uncollapsedText, + collapsed: shouldCollapse + }; + } + + toggleCollapseState(e) { + e.preventDefault(); + + const state = this.state; + state.text = state.collapsed ? state.uncollapsedText : state.collapsedText; + state.collapsed = !state.collapsed; + this.setState(state); + } + + shouldCollapse() { + const text = this.props.attachment.text || ''; + return (text.match(/\n/g) || []).length >= 5 || text.length > 700; + } + + getCollapsedText() { + let text = this.props.attachment.text || ''; + if ((text.match(/\n/g) || []).length >= 5) { + text = text.split('\n').splice(0, 5).join('\n'); + } else if (text.length > 700) { + text = text.substr(0, 700); + } + + return TextFormatting.formatText(text) + `<a class="attachment-link-more" href="#">${this.props.intl.formatMessage(holders.more)}</a>`; + } + + getFieldsTable() { + const fields = this.props.attachment.fields; + if (!fields || !fields.length) { + return ''; + } + + let fieldTables = []; + + let headerCols = []; + let bodyCols = []; + let rowPos = 0; + let lastWasLong = false; + let nrTables = 0; + + fields.forEach((field, i) => { + if (rowPos === 2 || !(field.short === true) || lastWasLong) { + fieldTables.push( + <table + className='attachment___fields' + key={'attachment__table__' + nrTables} + > + <thead> + <tr> + {headerCols} + </tr> + </thead> + <tbody> + <tr> + {bodyCols} + </tr> + </tbody> + </table> + ); + headerCols = []; + bodyCols = []; + rowPos = 0; + nrTables += 1; + lastWasLong = false; + } + headerCols.push( + <th + className='attachment___field-caption' + key={'attachment__field-caption-' + i + '__' + nrTables} + width='50%' + > + {field.title} + </th> + ); + bodyCols.push( + <td + className='attachment___field' + key={'attachment__field-' + i + '__' + nrTables} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(field.value || '')}} + > + </td> + ); + rowPos += 1; + lastWasLong = !(field.short === true); + }); + if (headerCols.length > 0) { // Flush last fields + fieldTables.push( + <table + className='attachment___fields' + key={'attachment__table__' + nrTables} + > + <thead> + <tr> + {headerCols} + </tr> + </thead> + <tbody> + <tr> + {bodyCols} + </tr> + </tbody> + </table> + ); + } + return ( + <div> + {fieldTables} + </div> + ); + } + + render() { + const data = this.props.attachment; + + let preText; + if (data.pretext) { + preText = ( + <div + className='attachment__thumb-pretext' + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(data.pretext)}} + > + </div> + ); + } + + let author = []; + if (data.author_name || data.author_icon) { + if (data.author_icon) { + author.push( + <img + className='attachment__author-icon' + src={data.author_icon} + key={'attachment__author-icon'} + height='14' + width='14' + /> + ); + } + if (data.author_name) { + author.push( + <span + className='attachment__author-name' + key={'attachment__author-name'} + > + {data.author_name} + </span> + ); + } + } + if (data.author_link) { + author = ( + <a + href={data.author_link} + target='_blank' + rel='noopener noreferrer' + > + {author} + </a> + ); + } + + let title; + if (data.title) { + if (data.title_link) { + title = ( + <h1 + className='attachment__title' + > + <a + className='attachment__title-link' + href={data.title_link} + target='_blank' + rel='noopener noreferrer' + > + {data.title} + </a> + </h1> + ); + } else { + title = ( + <h1 + className='attachment__title' + > + {data.title} + </h1> + ); + } + } + + let text; + if (data.text) { + text = ( + <div + className='attachment__text' + dangerouslySetInnerHTML={{__html: this.state.text}} + > + </div> + ); + } + + let image; + if (data.image_url) { + image = ( + <img + className='attachment__image' + src={data.image_url} + /> + ); + } + + let thumb; + if (data.thumb_url) { + thumb = ( + <div + className='attachment__thumb-container' + > + <img + src={data.thumb_url} + /> + </div> + ); + } + + const fields = this.getFieldsTable(); + + let useBorderStyle; + if (data.color && data.color[0] === '#') { + useBorderStyle = {borderLeftColor: data.color}; + } + + return ( + <div + className='attachment' + ref='attachment' + > + {preText} + <div className='attachment__content'> + <div + className={useBorderStyle ? 'clearfix attachment__container' : 'clearfix attachment__container attachment__container--' + data.color} + style={useBorderStyle} + > + {author} + {title} + <div> + <div + className={thumb ? 'attachment__body' : 'attachment__body attachment__body--no_thumb'} + > + {text} + {image} + {fields} + </div> + {thumb} + <div style={{clear: 'both'}}></div> + </div> + </div> + </div> + </div> + ); + } +} + +PostAttachment.propTypes = { + intl: intlShape.isRequired, + attachment: React.PropTypes.object.isRequired +}; + +export default injectIntl(PostAttachment); diff --git a/webapp/components/post_view/components/post_attachment_list.jsx b/webapp/components/post_view/components/post_attachment_list.jsx new file mode 100644 index 000000000..7da9efbee --- /dev/null +++ b/webapp/components/post_view/components/post_attachment_list.jsx @@ -0,0 +1,30 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PostAttachment from './post_attachment.jsx'; + +import React from 'react'; + +export default class PostAttachmentList extends React.Component { + render() { + let content = []; + this.props.attachments.forEach((attachment, i) => { + content.push( + <PostAttachment + attachment={attachment} + key={'att_' + i} + /> + ); + }); + + return ( + <div className='attachment_list'> + {content} + </div> + ); + } +} + +PostAttachmentList.propTypes = { + attachments: React.PropTypes.array.isRequired +}; diff --git a/webapp/components/post_view/components/post_attachment_oembed.jsx b/webapp/components/post_view/components/post_attachment_oembed.jsx new file mode 100644 index 000000000..359c7cc35 --- /dev/null +++ b/webapp/components/post_view/components/post_attachment_oembed.jsx @@ -0,0 +1,108 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import React from 'react'; + +export default class PostAttachmentOEmbed extends React.Component { + constructor(props) { + super(props); + this.fetchData = this.fetchData.bind(this); + + this.isLoading = false; + } + + componentWillMount() { + this.setState({data: {}}); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.link !== this.props.link) { + this.isLoading = false; + this.fetchData(nextProps.link); + } + } + + componentDidMount() { + this.fetchData(this.props.link); + } + + fetchData(link) { + if (!this.isLoading) { + this.isLoading = true; + let url = 'https://noembed.com/embed?nowrap=on'; + url += '&url=' + encodeURIComponent(link); + url += '&maxheight=' + this.props.provider.height; + return $.ajax({ + url, + dataType: 'jsonp', + success: (result) => { + this.isLoading = false; + if (result.error) { + this.setState({data: {}}); + } else { + this.setState({data: result}); + } + }, + error: () => { + this.setState({data: {}}); + } + }); + } + return null; + } + + render() { + let data = {}; + let content; + if ($.isEmptyObject(this.state.data)) { + content = <div style={{height: this.props.provider.height}}/>; + } else { + data = this.state.data; + content = ( + <div + style={{height: this.props.provider.height}} + dangerouslySetInnerHTML={{__html: data.html}} + /> + ); + } + + return ( + <div + className='attachment attachment--oembed' + ref='attachment' + > + <div className='attachment__content'> + <div + className={'clearfix attachment__container'} + > + <h1 + className='attachment__title' + > + <a + className='attachment__title-link' + href={data.url} + target='_blank' + rel='noopener noreferrer' + > + {data.title} + </a> + </h1> + <div > + <div + className={'attachment__body attachment__body--no_thumb'} + > + {content} + </div> + </div> + </div> + </div> + </div> + ); + } +} + +PostAttachmentOEmbed.propTypes = { + link: React.PropTypes.string.isRequired, + provider: React.PropTypes.object.isRequired +}; diff --git a/webapp/components/post_view/components/post_body.jsx b/webapp/components/post_view/components/post_body.jsx new file mode 100644 index 000000000..3c57db0cd --- /dev/null +++ b/webapp/components/post_view/components/post_body.jsx @@ -0,0 +1,186 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import FileAttachmentList from 'components/file_attachment_list.jsx'; +import UserStore from 'stores/user_store.jsx'; +import * as Utils from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; +import * as TextFormatting from 'utils/text_formatting.jsx'; +import PostBodyAdditionalContent from './post_body_additional_content.jsx'; +import PendingPostOptions from './pending_post_options.jsx'; + +import {FormattedMessage} from 'react-intl'; + +import loadingGif from 'images/load.gif'; + +import React from 'react'; + +export default class PostBody extends React.Component { + shouldComponentUpdate(nextProps) { + if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) { + return true; + } + + if (!Utils.areObjectsEqual(nextProps.parentPost, this.props.parentPost)) { + return true; + } + + if (!Utils.areObjectsEqual(nextProps.compactDisplay, this.props.compactDisplay)) { + return true; + } + + if (nextProps.handleCommentClick.toString() !== this.props.handleCommentClick.toString()) { + return true; + } + + return false; + } + + render() { + const post = this.props.post; + const filenames = this.props.post.filenames; + const parentPost = this.props.parentPost; + + let comment = ''; + let postClass = ''; + + if (parentPost) { + const profile = UserStore.getProfile(parentPost.user_id); + + let apostrophe = ''; + let name = '...'; + if (profile != null) { + let username = profile.username; + if (parentPost.props && + parentPost.props.from_webhook && + parentPost.props.override_username && + global.window.mm_config.EnablePostUsernameOverride === 'true') { + username = parentPost.props.override_username; + } + + if (username.slice(-1) === 's') { + apostrophe = '\''; + } else { + apostrophe = '\'s'; + } + name = ( + <a + className='theme' + onClick={Utils.searchForTerm.bind(null, username)} + > + {username} + </a> + ); + } + + let message = ''; + if (parentPost.message) { + message = Utils.replaceHtmlEntities(parentPost.message); + } else if (parentPost.filenames.length) { + message = parentPost.filenames[0].split('/').pop(); + + if (parentPost.filenames.length === 2) { + message += Utils.localizeMessage('post_body.plusOne', ' plus 1 other file'); + } else if (parentPost.filenames.length > 2) { + message += Utils.localizeMessage('post_body.plusMore', ' plus {count} other files').replace('{count}', (parentPost.filenames.length - 1).toString()); + } + } + + comment = ( + <div className='post__link'> + <span> + <FormattedMessage + id='post_body.commentedOn' + defaultMessage='Commented on {name}{apostrophe} message: ' + values={{ + name, + apostrophe + }} + /> + <a + className='theme' + onClick={this.props.handleCommentClick} + > + {message} + </a> + </span> + </div> + ); + } + + let loading; + if (post.state === Constants.POST_FAILED) { + postClass += ' post--fail'; + loading = <PendingPostOptions post={this.props.post}/>; + } else if (post.state === Constants.POST_LOADING) { + postClass += ' post-waiting'; + loading = ( + <img + className='post-loading-gif pull-right' + src={loadingGif} + /> + ); + } + + let fileAttachmentHolder = ''; + if (filenames && filenames.length > 0) { + fileAttachmentHolder = ( + <FileAttachmentList + + filenames={filenames} + channelId={post.channel_id} + userId={post.user_id} + compactDisplay={this.props.compactDisplay} + /> + ); + } + + let message; + let additionalContent = null; + if (this.props.post.state === Constants.POST_DELETED) { + message = ( + <FormattedMessage + id='post_body.deleted' + defaultMessage='(message deleted)' + /> + ); + } else { + message = ( + <span + onClick={TextFormatting.handleClick} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.props.post.message)}} + /> + ); + + additionalContent = ( + <PostBodyAdditionalContent post={this.props.post}/> + ); + } + + return ( + <div> + {comment} + <div className='post__body'> + <div + key={`${post.id}_message`} + id={`${post.id}_message`} + className={postClass} + > + {loading} + {message} + </div> + {fileAttachmentHolder} + {additionalContent} + </div> + </div> + ); + } +} + +PostBody.propTypes = { + post: React.PropTypes.object.isRequired, + parentPost: React.PropTypes.object, + retryPost: React.PropTypes.func.isRequired, + handleCommentClick: React.PropTypes.func.isRequired, + compactDisplay: React.PropTypes.bool +}; diff --git a/webapp/components/post_view/components/post_body_additional_content.jsx b/webapp/components/post_view/components/post_body_additional_content.jsx new file mode 100644 index 000000000..deabaaa9b --- /dev/null +++ b/webapp/components/post_view/components/post_body_additional_content.jsx @@ -0,0 +1,151 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PostAttachmentList from './post_attachment_list.jsx'; +import PostAttachmentOEmbed from './post_attachment_oembed.jsx'; +import PostImage from './post_image.jsx'; +import YoutubeVideo from 'components/youtube_video.jsx'; + +import Constants from 'utils/constants.jsx'; +import OEmbedProviders from './providers.json'; +import * as Utils from 'utils/utils.jsx'; + +import React from 'react'; + +export default class PostBodyAdditionalContent extends React.Component { + constructor(props) { + super(props); + + this.getSlackAttachment = this.getSlackAttachment.bind(this); + this.getOEmbedProvider = this.getOEmbedProvider.bind(this); + this.generateEmbed = this.generateEmbed.bind(this); + this.toggleEmbedVisibility = this.toggleEmbedVisibility.bind(this); + + this.state = { + embedVisible: true + }; + } + + shouldComponentUpdate(nextProps, nextState) { + if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) { + return true; + } + if (nextState.embedVisible !== this.state.embedVisible) { + return true; + } + return false; + } + + toggleEmbedVisibility() { + this.setState({embedVisible: !this.state.embedVisible}); + } + + getSlackAttachment() { + let attachments = []; + if (this.props.post.props && this.props.post.props.attachments) { + attachments = this.props.post.props.attachments; + } + + return ( + <PostAttachmentList + attachments={attachments} + /> + ); + } + + getOEmbedProvider(link) { + for (let i = 0; i < OEmbedProviders.length; i++) { + for (let j = 0; j < OEmbedProviders[i].patterns.length; j++) { + if (link.match(OEmbedProviders[i].patterns[j])) { + return OEmbedProviders[i]; + } + } + } + + return null; + } + + generateEmbed() { + if (this.props.post.type === 'slack_attachment') { + return this.getSlackAttachment(); + } + + const link = Utils.extractFirstLink(this.props.post.message); + if (!link) { + return null; + } + + if (Utils.isFeatureEnabled(Constants.PRE_RELEASE_FEATURES.EMBED_PREVIEW)) { + const provider = this.getOEmbedProvider(link); + + if (provider) { + return ( + <PostAttachmentOEmbed + provider={provider} + link={link} + /> + ); + } + } + + if (YoutubeVideo.isYoutubeLink(link)) { + return ( + <YoutubeVideo + channelId={this.props.post.channel_id} + link={link} + /> + ); + } + + for (let i = 0; i < Constants.IMAGE_TYPES.length; i++) { + const imageType = Constants.IMAGE_TYPES[i]; + const suffix = link.substring(link.length - (imageType.length + 1)); + if (suffix === '.' + imageType || suffix === '=' + imageType) { + return ( + <PostImage + channelId={this.props.post.channel_id} + link={link} + /> + ); + } + } + + return null; + } + + render() { + const generateEmbed = this.generateEmbed(); + + if (generateEmbed) { + let toggle; + if (Utils.isFeatureEnabled(Constants.PRE_RELEASE_FEATURES.EMBED_TOGGLE)) { + toggle = ( + <a + className='post__embed-visibility' + data-expanded={this.state.embedVisible} + aria-label='Toggle Embed Visibility' + onClick={this.toggleEmbedVisibility} + /> + ); + } + + return ( + <div> + {toggle} + <div + className='post__embed-container' + hidden={!this.state.embedVisible} + > + {generateEmbed} + </div> + </div> + ); + } + + return null; + } +} + +PostBodyAdditionalContent.propTypes = { + post: React.PropTypes.object.isRequired +}; diff --git a/webapp/components/post_view/components/post_header.jsx b/webapp/components/post_view/components/post_header.jsx new file mode 100644 index 000000000..3e7650d7f --- /dev/null +++ b/webapp/components/post_view/components/post_header.jsx @@ -0,0 +1,84 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import UserProfile from 'components/user_profile.jsx'; +import PostInfo from './post_info.jsx'; + +import * as PostUtils from 'utils/post_utils.jsx'; + +import Constants from 'utils/constants.jsx'; + +import React from 'react'; + +export default class PostHeader extends React.Component { + constructor(props) { + super(props); + this.state = {}; + } + + render() { + const post = this.props.post; + + let userProfile = <UserProfile user={this.props.user}/>; + let botIndicator; + + if (post.props && post.props.from_webhook) { + if (post.props.override_username && global.window.mm_config.EnablePostUsernameOverride === 'true') { + userProfile = ( + <UserProfile + user={this.props.user} + overwriteName={post.props.override_username} + disablePopover={true} + /> + ); + } + + botIndicator = <li className='col col__name bot-indicator'>{Constants.BOT_NAME}</li>; + } else if (PostUtils.isSystemMessage(post)) { + userProfile = ( + <UserProfile + user={{}} + overwriteName={Constants.SYSTEM_MESSAGE_PROFILE_NAME} + overwriteImage={Constants.SYSTEM_MESSAGE_PROFILE_IMAGE} + disablePopover={true} + /> + ); + } + + return ( + <ul className='post__header'> + <li className='col col__name'>{userProfile}</li> + {botIndicator} + <li className='col'> + <PostInfo + post={post} + commentCount={this.props.commentCount} + handleCommentClick={this.props.handleCommentClick} + allowReply='true' + isLastComment={this.props.isLastComment} + sameUser={this.props.sameUser} + currentUser={this.props.currentUser} + compactDisplay={this.props.compactDisplay} + /> + </li> + </ul> + ); + } +} + +PostHeader.defaultProps = { + post: null, + commentCount: 0, + isLastComment: false, + sameUser: false +}; +PostHeader.propTypes = { + post: React.PropTypes.object.isRequired, + user: React.PropTypes.object, + currentUser: React.PropTypes.object.isRequired, + commentCount: React.PropTypes.number.isRequired, + isLastComment: React.PropTypes.bool.isRequired, + handleCommentClick: React.PropTypes.func.isRequired, + sameUser: React.PropTypes.bool.isRequired, + compactDisplay: React.PropTypes.bool +}; diff --git a/webapp/components/post_view/components/post_image.jsx b/webapp/components/post_view/components/post_image.jsx new file mode 100644 index 000000000..d1d1a6c7a --- /dev/null +++ b/webapp/components/post_view/components/post_image.jsx @@ -0,0 +1,83 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +export default class PostImageEmbed extends React.Component { + constructor(props) { + super(props); + + this.handleLoadComplete = this.handleLoadComplete.bind(this); + this.handleLoadError = this.handleLoadError.bind(this); + + this.state = { + loaded: false, + errored: false + }; + } + + componentWillMount() { + this.loadImg(this.props.link); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.link !== this.props.link) { + this.setState({ + loaded: false, + errored: false + }); + } + } + + componentDidUpdate(prevProps) { + if (!this.state.loaded && prevProps.link !== this.props.link) { + this.loadImg(this.props.link); + } + } + + loadImg(src) { + const img = new Image(); + img.onload = this.handleLoadComplete; + img.onerror = this.handleLoadError; + img.src = src; + } + + handleLoadComplete() { + this.setState({ + loaded: true + }); + } + + handleLoadError() { + this.setState({ + errored: true, + loaded: true + }); + } + + render() { + if (this.state.errored) { + return null; + } + + if (!this.state.loaded) { + return ( + <img + className='img-div placeholder' + height='500px' + /> + ); + } + + return ( + <img + className='img-div' + src={this.props.link} + /> + ); + } +} + +PostImageEmbed.propTypes = { + link: React.PropTypes.string.isRequired +}; diff --git a/webapp/components/post_view/components/post_info.jsx b/webapp/components/post_view/components/post_info.jsx new file mode 100644 index 000000000..cc8c0a842 --- /dev/null +++ b/webapp/components/post_view/components/post_info.jsx @@ -0,0 +1,257 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import * as Utils from 'utils/utils.jsx'; +import TimeSince from 'components/time_since.jsx'; +import * as GlobalActions from 'actions/global_actions.jsx'; +import TeamStore from 'stores/team_store.jsx'; +import UserStore from 'stores/user_store.jsx'; + +import Constants from 'utils/constants.jsx'; + +import {FormattedMessage} from 'react-intl'; + +import React from 'react'; + +export default class PostInfo extends React.Component { + constructor(props) { + super(props); + + this.dropdownPosition = this.dropdownPosition.bind(this); + this.handlePermalink = this.handlePermalink.bind(this); + this.removePost = this.removePost.bind(this); + } + dropdownPosition(e) { + var position = $('#post-list').height() - $(e.target).offset().top; + var dropdown = $(e.target).closest('.col__reply').find('.dropdown-menu'); + if (position < dropdown.height()) { + dropdown.addClass('bottom'); + } + } + createDropdown() { + var post = this.props.post; + var isOwner = this.props.currentUser.id === post.user_id; + var isAdmin = TeamStore.isTeamAdminForCurrentTeam() || UserStore.isSystemAdminForCurrentUser(); + const isSystemMessage = post.type && post.type.startsWith(Constants.SYSTEM_MESSAGE_PREFIX); + + if (post.state === Constants.POST_FAILED || post.state === Constants.POST_LOADING || Utils.isPostEphemeral(post)) { + return ''; + } + + var type = 'Post'; + if (post.root_id && post.root_id.length > 0) { + type = 'Comment'; + } + + var dropdownContents = []; + var dataComments = 0; + if (type === 'Post') { + dataComments = this.props.commentCount; + } + + if (this.props.allowReply === 'true') { + dropdownContents.push( + <li + key='replyLink' + role='presentation' + > + <a + className='link__reply theme' + href='#' + onClick={this.props.handleCommentClick} + > + <FormattedMessage + id='post_info.reply' + defaultMessage='Reply' + /> + </a> + </li> + ); + } + + if (!Utils.isMobile()) { + dropdownContents.push( + <li + key='copyLink' + role='presentation' + > + <a + href='#' + onClick={this.handlePermalink} + > + <FormattedMessage + id='post_info.permalink' + defaultMessage='Permalink' + /> + </a> + </li> + ); + } + + if (isOwner || isAdmin) { + dropdownContents.push( + <li + key='deletePost' + role='presentation' + > + <a + href='#' + role='menuitem' + onClick={() => GlobalActions.showDeletePostModal(post, dataComments)} + > + <FormattedMessage + id='post_info.del' + defaultMessage='Delete' + /> + </a> + </li> + ); + } + + if (isOwner && !isSystemMessage) { + dropdownContents.push( + <li + key='editPost' + role='presentation' + > + <a + href='#' + role='menuitem' + data-toggle='modal' + data-target='#edit_post' + data-refocusid='#post_textbox' + data-title={type} + data-message={post.message} + data-postid={post.id} + data-channelid={post.channel_id} + data-comments={dataComments} + > + <FormattedMessage + id='post_info.edit' + defaultMessage='Edit' + /> + </a> + </li> + ); + } + + if (dropdownContents.length === 0) { + return ''; + } + + return ( + <div> + <a + href='#' + className='dropdown-toggle post__dropdown theme' + type='button' + data-toggle='dropdown' + aria-expanded='false' + onClick={this.dropdownPosition} + /> + <ul + className='dropdown-menu' + role='menu' + > + {dropdownContents} + </ul> + </div> + ); + } + + handlePermalink(e) { + e.preventDefault(); + GlobalActions.showGetPostLinkModal(this.props.post); + } + + removePost() { + GlobalActions.emitRemovePost(this.props.post); + } + createRemovePostButton(post) { + if (!Utils.isPostEphemeral(post)) { + return null; + } + + return ( + <a + href='#' + className='post__remove theme' + type='button' + onClick={this.removePost} + > + {'×'} + </a> + ); + } + render() { + var post = this.props.post; + var comments = ''; + var showCommentClass = ''; + var commentCountText = this.props.commentCount; + + if (this.props.commentCount >= 1) { + showCommentClass = ' icon--show'; + } else { + commentCountText = ''; + } + + if (post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && !Utils.isPostEphemeral(post)) { + comments = ( + <a + href='#' + className={'comment-icon__container' + showCommentClass} + onClick={this.props.handleCommentClick} + > + <span + className='comment-icon' + dangerouslySetInnerHTML={{__html: Constants.REPLY_ICON}} + /> + {commentCountText} + </a> + ); + } + + var dropdown = this.createDropdown(); + + return ( + <ul className='post__header--info'> + <li className='col'> + <TimeSince + eventTime={post.create_at} + sameUser={this.props.sameUser} + compactDisplay={this.props.compactDisplay} + /> + </li> + <li className='col col__reply'> + <div + className='dropdown' + ref='dotMenu' + > + {dropdown} + </div> + {comments} + {this.createRemovePostButton(post)} + </li> + </ul> + ); + } +} + +PostInfo.defaultProps = { + post: null, + commentCount: 0, + isLastComment: false, + allowReply: false, + sameUser: false +}; +PostInfo.propTypes = { + post: React.PropTypes.object.isRequired, + commentCount: React.PropTypes.number.isRequired, + isLastComment: React.PropTypes.bool.isRequired, + allowReply: React.PropTypes.string.isRequired, + handleCommentClick: React.PropTypes.func.isRequired, + sameUser: React.PropTypes.bool.isRequired, + currentUser: React.PropTypes.object.isRequired, + compactDisplay: React.PropTypes.bool +}; diff --git a/webapp/components/post_view/components/post_list.jsx b/webapp/components/post_view/components/post_list.jsx new file mode 100644 index 000000000..288609cd9 --- /dev/null +++ b/webapp/components/post_view/components/post_list.jsx @@ -0,0 +1,524 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; + +import Post from './post.jsx'; +import FloatingTimestamp from './floating_timestamp.jsx'; +import ScrollToBottomArrows from './scroll_to_bottom_arrows.jsx'; + +import * as GlobalActions from 'actions/global_actions.jsx'; + +import {createChannelIntroMessage} from 'utils/channel_intro_messages.jsx'; + +import * as Utils from 'utils/utils.jsx'; +import * as PostUtils from 'utils/post_utils.jsx'; +import DelayedAction from 'utils/delayed_action.jsx'; + +import Constants from 'utils/constants.jsx'; +const ScrollTypes = Constants.ScrollTypes; + +import {FormattedDate, FormattedMessage} from 'react-intl'; + +import React from 'react'; +import ReactDOM from 'react-dom'; + +export default class PostList extends React.Component { + constructor(props) { + super(props); + + 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); + + if (props.channel) { + this.introText = createChannelIntroMessage(props.channel); + } else { + this.introText = this.getArchivesIntroMessage(); + } + + this.state = { + isScrolling: false, + topPostId: null + }; + } + + 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 / Constants.SCROLL_PAGE_FRACTION))) { + this.jumpToPostNode = childNodes[i]; + break; + } + } + this.wasAtBottom = this.isAtBottom(); + if (!this.jumpToPostNode && childNodes.length > 0) { + this.jumpToPostNode = childNodes[childNodes.length - 1]; + } + + // --- -------- + + this.props.postListScrolled(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(Constants.SCROLL_DELAY); + } + + handleScrollStop() { + this.setState({ + isScrolling: false + }); + } + + updateFloatingTimestamp() { + // skip this in non-mobile view since that's when the timestamp is visible + if (!Utils.isMobile()) { + 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 = 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() { + GlobalActions.emitLoadMorePostsEvent(); + } + + loadMorePostsBottom() { + GlobalActions.emitLoadMorePostsFocusedBottomEvent(); + } + + 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 = PostUtils.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 = PostUtils.isComment(post); + const prevPostIsComment = PostUtils.isComment(prevPost); + const postFromWebhook = Boolean(post.props && post.props.from_webhook); + const prevPostFromWebhook = Boolean(prevPost.props && prevPost.props.from_webhook); + const prevPostUserId = PostUtils.isSystemMessage(prevPost) ? '' : prevPost.user_id; + + // 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 current post is not from a webhook + // the previous post is not from a webhook + if (prevPostUserId === postUserId && + post.create_at - prevPost.create_at <= Constants.POST_COLLAPSE_TIMEOUT && + !postFromWebhook && !prevPostFromWebhook) { + 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 is not from a webhook + // the current post is not from a webhook + if (prevPostUserId === postUserId && + !prevPostIsComment && + !postIsComment && + !prevPostFromWebhook && + !postFromWebhook) { + 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 = PostUtils.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 (userId === post.user_id) { + profile = this.props.currentUser; + } else { + profile = profiles[post.user_id]; + } + + let commentCount = 0; + let commentRootId; + if (parentPost) { + commentRootId = post.root_id; + } else { + commentRootId = post.id; + } + for (const postId in posts) { + if (posts[postId].root_id === commentRootId) { + commentCount += 1; + } + } + + const postCtl = ( + <Post + key={keyPrefix + 'postKey'} + ref={post.id} + sameUser={sameUser} + sameRoot={sameRoot} + post={post} + parentPost={parentPost} + hideProfilePic={hideProfilePic} + isLastComment={isLastComment} + shouldHighlight={shouldHighlight} + displayNameType={this.props.displayNameType} + user={profile} + currentUser={this.props.currentUser} + center={this.props.displayPostsInCenter} + commentCount={commentCount} + compactDisplay={this.props.compactDisplay} + /> + ); + + 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.lastViewed !== 0 && + post.create_at > this.props.lastViewed && + !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 === ScrollTypes.BOTTOM) { + this.scrollToBottom(); + } else if (this.props.scrollType === ScrollTypes.NEW_MESSAGE) { + window.setTimeout(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; + } + }), 0); + } else if (this.props.scrollType === ScrollTypes.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 / Constants.SCROLL_PAGE_FRACTION); + } else { + this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / Constants.SCROLL_PAGE_FRACTION) + (this.refs.postlist.scrollTop - postNode.offsetTop); + } + }); + } else if (this.props.scrollType === ScrollTypes.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 / Constants.SCROLL_PAGE_FRACTION); + } else { + this.refs.postlist.scrollTop -= (this.refs.postlist.offsetHeight / Constants.SCROLL_PAGE_FRACTION) + (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'); + } + + getArchivesIntroMessage() { + return ( + <div className='channel-intro'> + <h4 className='channel-intro__title'> + <FormattedMessage + id='post_focus_view.beginning' + defaultMessage='Beginning of Channel Archives' + /> + </h4> + </div> + ); + } + + componentDidMount() { + if (this.props.postList != null) { + this.updateScrolling(); + } + + window.addEventListener('resize', this.handleResize); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.handleResize); + this.scrollStopAction.cancel(); + } + + componentDidUpdate() { + if (this.props.postList != null) { + this.updateScrolling(); + } + } + + render() { + if (this.props.postList == null) { + return <div/>; + } + + const posts = this.props.postList.posts; + const order = this.props.postList.order; + + // Create intro message or top loadmore link + let moreMessagesTop; + 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.introText; + } + + // Give option to load more posts at bottom if necessary + let moreMessagesBottom; + if (this.props.showMoreMessagesBottom) { + moreMessagesBottom = ( + <a + ref='loadmorebottom' + className='more-messages-text theme' + href='#' + onClick={this.loadMorePostsBottom} + > + <FormattedMessage id='posts_view.loadMore'/> + </a> + ); + } + + // Create post elements + const postElements = this.createPosts(posts, order); + + let topPostCreateAt = 0; + if (this.state.topPostId && this.props.postList.posts[this.state.topPostId]) { + topPostCreateAt = this.props.postList.posts[this.state.topPostId].create_at; + } + + return ( + <div> + <FloatingTimestamp + isScrolling={this.state.isScrolling} + isMobile={Utils.isMobile()} + createAt={topPostCreateAt} + /> + <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> + ); + } +} + +PostList.defaultProps = { + lastViewed: 0 +}; + +PostList.propTypes = { + postList: React.PropTypes.object, + profiles: React.PropTypes.object, + channel: React.PropTypes.object, + currentUser: React.PropTypes.object, + scrollPostId: React.PropTypes.string, + scrollType: React.PropTypes.number, + postListScrolled: React.PropTypes.func.isRequired, + showMoreMessagesTop: React.PropTypes.bool, + showMoreMessagesBottom: React.PropTypes.bool, + lastViewed: React.PropTypes.number, + postsToHighlight: React.PropTypes.object, + displayNameType: React.PropTypes.string, + displayPostsInCenter: React.PropTypes.bool, + compactDisplay: React.PropTypes.bool +}; diff --git a/webapp/components/post_view/components/providers.json b/webapp/components/post_view/components/providers.json new file mode 100644 index 000000000..b5899c225 --- /dev/null +++ b/webapp/components/post_view/components/providers.json @@ -0,0 +1,376 @@ +[ + { + "patterns": [ + "http://(?:www\\.)?xkcd\\.com/\\d+/?" + ], + "name": "XKCD", + "height": 110 + }, + { + "patterns": [ + "https?://soundcloud.com/.*/.*" + ], + "name": "SoundCloud", + "height": 140 + }, + { + "patterns": [ + "https?://(?:www\\.)?flickr\\.com/.*", + "https?://flic\\.kr/p/[a-zA-Z0-9]+" + ], + "name": "Flickr", + "height": 110 + }, + { + "patterns": [ + "http://www\\.ted\\.com/talks/.+\\.html" + ], + "name": "TED", + "height": 110 + }, + { + "patterns": [ + "http://(?:www\\.)?theverge\\.com/\\d{4}/\\d{1,2}/\\d{1,2}/\\d+/[^/]+/?$" + ], + "name": "The Verge", + "height": 110 + }, + { + "patterns": [ + "http://.*\\.viddler\\.com/.*" + ], + "name": "Viddler", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www\\.)?avclub\\.com/article/[^/]+/?$" + ], + "name": "The AV Club", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www\\.)?wired\\.com/([^/]+/)?\\d+/\\d+/[^/]+/?$" + ], + "name": "Wired", + "height": 110 + }, + { + "patterns": [ + "http://www\\.theonion\\.com/articles/[^/]+/?" + ], + "name": "The Onion", + "height": 110 + }, + { + "patterns": [ + "http://yfrog\\.com/[0-9a-zA-Z]+/?$" + ], + "name": "YFrog", + "height": 110 + }, + { + "patterns": [ + "http://www\\.duffelblog\\.com/\\d{4}/\\d{1,2}/[^/]+/?$" + ], + "name": "The Duffel Blog", + "height": 110 + }, + { + "patterns": [ + "http://www\\.clickhole\\.com/article/[^/]+/?" + ], + "name": "Clickhole", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www.)?skitch.com/([^/]+)/[^/]+/.+", + "http://skit.ch/[^/]+" + ], + "name": "Skitch", + "height": 110 + }, + { + "patterns": [ + "https?://(alpha|posts|photos)\\.app\\.net/.*" + ], + "name": "ADN", + "height": 110 + }, + { + "patterns": [ + "https?://gist\\.github\\.com/(?:[-0-9a-zA-Z]+/)?([0-9a-fA-f]+)" + ], + "name": "Gist", + "height": 110 + }, + { + "patterns": [ + "https?://www\\.(dropbox\\.com/s/.+\\.(?:jpg|png|gif))", + "https?://db\\.tt/[a-zA-Z0-9]+" + ], + "name": "Dropbox", + "height": 110 + }, + { + "patterns": [ + "https?://[^\\.]+\\.wikipedia\\.org/wiki/(?!Talk:)[^#]+(?:#(.+))?" + ], + "name": "Wikipedia", + "height": 110 + }, + { + "patterns": [ + "http://www.traileraddict.com/trailer/[^/]+/trailer" + ], + "name": "TrailerAddict", + "height": 110 + }, + { + "patterns": [ + "http://lockerz\\.com/[sd]/\\d+" + ], + "name": "Lockerz", + "height": 110 + }, + { + "patterns": [ + "http://gifuk\\.com/s/[0-9a-f]{16}" + ], + "name": "GIFUK", + "height": 110 + }, + { + "patterns": [ + "http://trailers\\.apple\\.com/trailers/[^/]+/[^/]+" + ], + "name": "iTunes Movie Trailers", + "height": 110 + }, + { + "patterns": [ + "http://gfycat\\.com/([a-zA-Z]+)" + ], + "name": "Gfycat", + "height": 110 + }, + { + "patterns": [ + "http://bash\\.org/\\?(\\d+)" + ], + "name": "Bash.org", + "height": 110 + }, + { + "patterns": [ + "http://arstechnica\\.com/[^/]+/\\d+/\\d+/[^/]+/?$" + ], + "name": "Ars Technica", + "height": 110 + }, + { + "patterns": [ + "http://imgur\\.com/gallery/[0-9a-zA-Z]+" + ], + "name": "Imgur", + "height": 110 + }, + { + "patterns": [ + "http://www\\.asciiartfarts\\.com/[0-9]+\\.html" + ], + "name": "ASCII Art Farts", + "height": 110 + }, + { + "patterns": [ + "http://www\\.monoprice\\.com/products/product\\.asp\\?.*p_id=\\d+" + ], + "name": "Monoprice", + "height": 110 + }, + { + "patterns": [ + "http://boingboing\\.net/\\d{4}/\\d{2}/\\d{2}/[^/]+\\.html" + ], + "name": "Boing Boing", + "height": 110 + }, + { + "patterns": [ + "https?://github\\.com/([^/]+)/([^/]+)/commit/(.+)", + "http://git\\.io/[_0-9a-zA-Z]+" + ], + "name": "Github Commit", + "height": 110 + }, + { + "patterns": [ + "https?://open\\.spotify\\.com/(track|album)/([0-9a-zA-Z]{22})" + ], + "name": "Spotify", + "height": 110 + }, + { + "patterns": [ + "https?://path\\.com/p/([0-9a-zA-Z]+)$" + ], + "name": "Path", + "height": 110 + }, + { + "patterns": [ + "http://www.funnyordie.com/videos/[^/]+/.+" + ], + "name": "Funny or Die", + "height": 110 + }, + { + "patterns": [ + "http://(?:www\\.)?twitpic\\.com/([^/]+)" + ], + "name": "Twitpic", + "height": 110 + }, + { + "patterns": [ + "https?://www\\.giantbomb\\.com/videos/[^/]+/\\d+-\\d+/?" + ], + "name": "GiantBomb", + "height": 110 + }, + { + "patterns": [ + "http://(?:www\\.)?beeradvocate\\.com/beer/profile/\\d+/\\d+" + ], + "name": "Beer Advocate", + "height": 110 + }, + { + "patterns": [ + "http://(?:www\\.)?imdb.com/title/(tt\\d+)" + ], + "name": "IMDB", + "height": 110 + }, + { + "patterns": [ + "http://cl\\.ly/(?:image/)?[0-9a-zA-Z]+/?$" + ], + "name": "CloudApp", + "height": 110 + }, + { + "patterns": [ + "http://clyp\\.it/.*" + ], + "name": "Clyp", + "height": 110 + }, + { + "patterns": [ + "http://www\\.hulu\\.com/watch/.*" + ], + "name": "Hulu", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www|mobile\\.)?twitter\\.com/(?:#!/)?[^/]+/status(?:es)?/(\\d+)/?$", + "https?://t\\.co/[a-zA-Z0-9]+" + ], + "name": "Twitter", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www\\.)?vimeo\\.com/.+" + ], + "name": "Vimeo", + "height": 110 + }, + { + "patterns": [ + "http://www\\.amazon\\.com/(?:.+/)?[gd]p/(?:product/)?(?:tags-on-product/)?([a-zA-Z0-9]+)", + "http://amzn\\.com/([^/]+)" + ], + "name": "Amazon", + "height": 110 + }, + { + "patterns": [ + "http://qik\\.com/video/.*" + ], + "name": "Qik", + "height": 110 + }, + { + "patterns": [ + "http://www\\.rdio\\.com/artist/[^/]+/album/[^/]+/?", + "http://www\\.rdio\\.com/artist/[^/]+/album/[^/]+/track/[^/]+/?", + "http://www\\.rdio\\.com/people/[^/]+/playlists/\\d+/[^/]+" + ], + "name": "Rdio", + "height": 110 + }, + { + "patterns": [ + "http://www\\.slideshare\\.net/.*/.*" + ], + "name": "SlideShare", + "height": 110 + }, + { + "patterns": [ + "http://imgur\\.com/([0-9a-zA-Z]+)$" + ], + "name": "Imgur", + "height": 110 + }, + { + "patterns": [ + "https?://instagr(?:\\.am|am\\.com)/p/.+" + ], + "name": "Instagram", + "height": 110 + }, + { + "patterns": [ + "http://www\\.twitlonger\\.com/show/[a-zA-Z0-9]+", + "http://tl\\.gd/[^/]+" + ], + "name": "Twitlonger", + "height": 110 + }, + { + "patterns": [ + "https?://vine.co/v/[a-zA-Z0-9]+" + ], + "name": "Vine", + "height": 490 + }, + { + "patterns": [ + "http://www\\.urbandictionary\\.com/define\\.php\\?term=.+" + ], + "name": "Urban Dictionary", + "height": 110 + }, + { + "patterns": [ + "http://picplz\\.com/user/[^/]+/pic/[^/]+" + ], + "name": "Picplz", + "height": 110 + }, + { + "patterns": [ + "https?://(?:www\\.)?twitter\\.com/(?:#!/)?[^/]+/status(?:es)?/(\\d+)/photo/\\d+(?:/large|/)?$", + "https?://pic\\.twitter\\.com/.+" + ], + "name": "Twitter", + "height": 110 + } +] diff --git a/webapp/components/post_view/components/scroll_to_bottom_arrows.jsx b/webapp/components/post_view/components/scroll_to_bottom_arrows.jsx new file mode 100644 index 000000000..461ca3358 --- /dev/null +++ b/webapp/components/post_view/components/scroll_to_bottom_arrows.jsx @@ -0,0 +1,37 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; + +import Constants from 'utils/constants.jsx'; + +import React from 'react'; + +export default class ScrollToBottomArrows extends React.Component { + render() { + // only show on mobile + if ($(window).width() > 768) { + return <noscript/>; + } + + let className = 'post-list__arrows'; + if (this.props.isScrolling && !this.props.atBottom) { + className += ' scrolling'; + } + + return ( + <div + className={className} + onClick={this.props.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 +}; diff --git a/webapp/components/post_view/post_focus_view_controller.jsx b/webapp/components/post_view/post_focus_view_controller.jsx new file mode 100644 index 000000000..7c1da6566 --- /dev/null +++ b/webapp/components/post_view/post_focus_view_controller.jsx @@ -0,0 +1,128 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PostList from './components/post_list.jsx'; +import LoadingScreen from 'components/loading_screen.jsx'; + +import PostStore from 'stores/post_store.jsx'; +import UserStore from 'stores/user_store.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; + +import Constants from 'utils/constants.jsx'; +const ScrollTypes = Constants.ScrollTypes; + +import React from 'react'; + +export default class PostFocusView extends React.Component { + constructor(props) { + super(props); + + this.onChannelChange = this.onChannelChange.bind(this); + this.onPostsChange = this.onPostsChange.bind(this); + this.onUserChange = this.onUserChange.bind(this); + this.onPostListScroll = this.onPostListScroll.bind(this); + + const focusedPostId = PostStore.getFocusedPostId(); + + const channel = ChannelStore.getCurrent(); + let profiles = UserStore.getProfiles(); + if (channel && channel.type === Constants.DM_CHANNEL) { + profiles = Object.assign({}, profiles, UserStore.getDirectProfiles()); + } + + this.state = { + postList: PostStore.getVisiblePosts(focusedPostId), + currentUser: UserStore.getCurrentUser(), + profiles, + scrollType: ScrollTypes.POST, + currentChannel: ChannelStore.getCurrentId().slice(), + scrollPostId: focusedPostId, + atTop: PostStore.getVisibilityAtTop(focusedPostId), + atBottom: PostStore.getVisibilityAtBottom(focusedPostId) + }; + } + + componentDidMount() { + ChannelStore.addChangeListener(this.onChannelChange); + PostStore.addChangeListener(this.onPostsChange); + UserStore.addChangeListener(this.onUserChange); + } + + componentWillUnmount() { + ChannelStore.removeChangeListener(this.onChannelChange); + PostStore.removeChangeListener(this.onPostsChange); + UserStore.removeChangeListener(this.onUserChange); + } + + onChannelChange() { + const currentChannel = ChannelStore.getCurrentId(); + if (this.state.currentChannel !== currentChannel) { + this.setState({ + currentChannel: currentChannel.slice(), + scrollType: ScrollTypes.POST + }); + } + } + + onPostsChange() { + const focusedPostId = PostStore.getFocusedPostId(); + if (focusedPostId == null) { + return; + } + + this.setState({ + scrollPostId: focusedPostId, + postList: PostStore.getVisiblePosts(focusedPostId), + atTop: PostStore.getVisibilityAtTop(focusedPostId), + atBottom: PostStore.getVisibilityAtBottom(focusedPostId) + }); + } + + onUserChange() { + const channel = ChannelStore.getCurrent(); + let profiles = UserStore.getProfiles(); + if (channel && channel.type === Constants.DM_CHANNEL) { + profiles = Object.assign({}, profiles, UserStore.getDirectProfiles()); + } + this.setState({currentUser: UserStore.getCurrentUser(), profiles: JSON.parse(JSON.stringify(profiles))}); + } + + onPostListScroll() { + this.setState({scrollType: ScrollTypes.FREE}); + } + + render() { + const postsToHighlight = {}; + postsToHighlight[this.state.scrollPostId] = true; + + let content; + if (this.state.postList == null) { + content = ( + <LoadingScreen + position='absolute' + key='loading' + /> + ); + } else { + content = ( + <PostList + postList={this.state.postList} + currentUser={this.state.currentUser} + profiles={this.state.profiles} + scrollType={this.state.scrollType} + scrollPostId={this.state.scrollPostId} + postListScrolled={this.onPostListScroll} + showMoreMessagesTop={!this.state.atTop} + showMoreMessagesBottom={!this.state.atBottom} + postsToHighlight={postsToHighlight} + /> + ); + } + + return ( + <div id='post-list'> + {content} + </div> + ); + } +} diff --git a/webapp/components/post_view/post_view_cache.jsx b/webapp/components/post_view/post_view_cache.jsx new file mode 100644 index 000000000..8876ae461 --- /dev/null +++ b/webapp/components/post_view/post_view_cache.jsx @@ -0,0 +1,85 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information + +import PostViewController from './post_view_controller.jsx'; + +import ChannelStore from 'stores/channel_store.jsx'; + +import React from 'react'; + +const MAXIMUM_CACHED_VIEWS = 5; + +export default class PostViewCache extends React.Component { + constructor(props) { + super(props); + + this.onChannelChange = this.onChannelChange.bind(this); + + const channel = ChannelStore.getCurrent(); + + this.state = { + currentChannelId: channel.id, + channels: [channel] + }; + } + + componentDidMount() { + ChannelStore.addChangeListener(this.onChannelChange); + } + + componentWillUnmount() { + ChannelStore.removeChangeListener(this.onChannelChange); + } + + onChannelChange() { + const channels = Object.assign([], this.state.channels); + const currentChannel = ChannelStore.getCurrent(); + + if (currentChannel == null) { + return; + } + + // make sure current channel really changed + if (currentChannel.id === this.state.currentChannelId) { + return; + } + + if (channels.length > MAXIMUM_CACHED_VIEWS) { + channels.shift(); + } + + const index = channels.map((c) => c.id).indexOf(currentChannel.id); + if (index !== -1) { + channels.splice(index, 1); + } + + channels.push(currentChannel); + + this.setState({ + currentChannelId: currentChannel.id, + channels + }); + } + + render() { + const channels = this.state.channels; + const currentChannelId = this.state.currentChannelId; + + let postViews = []; + for (let i = 0; i < channels.length; i++) { + postViews.push( + <PostViewController + key={'postviewcontroller_' + channels[i].id} + channel={channels[i]} + active={channels[i].id === currentChannelId} + /> + ); + } + + return ( + <div id='post-list'> + {postViews} + </div> + ); + } +} diff --git a/webapp/components/post_view/post_view_controller.jsx b/webapp/components/post_view/post_view_controller.jsx new file mode 100644 index 000000000..0898a9ce6 --- /dev/null +++ b/webapp/components/post_view/post_view_controller.jsx @@ -0,0 +1,264 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PostList from './components/post_list.jsx'; +import LoadingScreen from 'components/loading_screen.jsx'; + +import PreferenceStore from 'stores/preference_store.jsx'; +import UserStore from 'stores/user_store.jsx'; +import PostStore from 'stores/post_store.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; + +import * as Utils from 'utils/utils.jsx'; + +import Constants from 'utils/constants.jsx'; +const Preferences = Constants.Preferences; +const ScrollTypes = Constants.ScrollTypes; + +import React from 'react'; + +export default class PostViewController extends React.Component { + constructor(props) { + super(props); + + this.onPreferenceChange = this.onPreferenceChange.bind(this); + this.onUserChange = this.onUserChange.bind(this); + this.onPostsChange = this.onPostsChange.bind(this); + this.onPostsViewJumpRequest = this.onPostsViewJumpRequest.bind(this); + this.onPostListScroll = this.onPostListScroll.bind(this); + this.onActivate = this.onActivate.bind(this); + this.onDeactivate = this.onDeactivate.bind(this); + + const channel = props.channel; + let profiles = UserStore.getProfiles(); + if (channel && channel.type === Constants.DM_CHANNEL) { + profiles = Object.assign({}, profiles, UserStore.getDirectProfiles()); + } + + let lastViewed = Number.MAX_VALUE; + const member = ChannelStore.getMember(channel.id); + if (member != null) { + lastViewed = member.last_viewed_at; + } + + this.state = { + channel, + postList: PostStore.getVisiblePosts(channel.id), + currentUser: UserStore.getCurrentUser(), + profiles, + atTop: PostStore.getVisibilityAtTop(channel.id), + lastViewed, + scrollType: ScrollTypes.BOTTOM, + displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'), + displayPostsInCenter: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_CENTERED, + compactDisplay: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT + }; + } + + componentDidMount() { + if (this.props.active) { + this.onActivate(); + } + } + + componentWillUnmount() { + if (this.props.active) { + this.onDeactivate(); + } + } + + onPreferenceChange() { + this.setState({ + displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'), + displayPostsInCenter: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_CENTERED, + compactDisplay: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT + }); + } + + onUserChange() { + const channel = this.state.channel; + let profiles = UserStore.getProfiles(); + if (channel && channel.type === Constants.DM_CHANNEL) { + profiles = Object.assign({}, profiles, UserStore.getDirectProfiles()); + } + this.setState({currentUser: UserStore.getCurrentUser(), profiles: JSON.parse(JSON.stringify(profiles))}); + } + + onPostsChange() { + this.setState({ + postList: JSON.parse(JSON.stringify(PostStore.getVisiblePosts(this.state.channel.id))), + atTop: PostStore.getVisibilityAtTop(this.state.channel.id) + }); + } + + onActivate() { + PreferenceStore.addChangeListener(this.onPreferenceChange); + UserStore.addChangeListener(this.onUserChange); + PostStore.addChangeListener(this.onPostsChange); + PostStore.addPostsViewJumpListener(this.onPostsViewJumpRequest); + } + + onDeactivate() { + PreferenceStore.removeChangeListener(this.onPreferenceChange); + UserStore.removeChangeListener(this.onUserChange); + PostStore.removeChangeListener(this.onPostsChange); + PostStore.removePostsViewJumpListener(this.onPostsViewJumpRequest); + } + + componentWillReceiveProps(nextProps) { + if (this.props.active && !nextProps.active) { + this.onDeactivate(); + } else if (!this.props.active && nextProps.active) { + this.onActivate(); + + const channel = nextProps.channel; + + let lastViewed = Number.MAX_VALUE; + const member = ChannelStore.getMember(channel.id); + if (member != null) { + lastViewed = member.last_viewed_at; + } + + let profiles = UserStore.getProfiles(); + if (channel && channel.type === Constants.DM_CHANNEL) { + profiles = Object.assign({}, profiles, UserStore.getDirectProfiles()); + } + + this.setState({ + channel, + lastViewed, + profiles: JSON.parse(JSON.stringify(profiles)), + postList: JSON.parse(JSON.stringify(PostStore.getVisiblePosts(channel.id))), + displayNameType: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', 'false'), + displayPostsInCenter: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_CENTERED, + compactDisplay: PreferenceStore.get(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT, + scrollType: ScrollTypes.BOTTOM + }); + } + } + + onPostsViewJumpRequest(type, postId) { + switch (type) { + case Constants.PostsViewJumpTypes.BOTTOM: + this.setState({scrollType: ScrollTypes.BOTTOM}); + break; + case Constants.PostsViewJumpTypes.POST: + this.setState({ + scrollType: ScrollTypes.POST, + scrollPostId: postId + }); + break; + case Constants.PostsViewJumpTypes.SIDEBAR_OPEN: + this.setState({scrollType: ScrollTypes.SIDEBAR_OPEN}); + break; + } + } + + onPostListScroll(atBottom) { + if (atBottom) { + this.setState({scrollType: ScrollTypes.BOTTOM}); + } else { + this.setState({scrollType: ScrollTypes.FREE}); + } + } + + shouldComponentUpdate(nextProps, nextState) { + if (nextProps.active !== this.props.active) { + return true; + } + + if (nextState.atTop !== this.state.atTop) { + return true; + } + + if (nextState.displayNameType !== this.state.displayNameType) { + return true; + } + + if (nextState.displayPostsInCenter !== this.state.displayPostsInCenter) { + return true; + } + + if (nextState.compactDisplay !== this.state.compactDisplay) { + return true; + } + + if (nextState.lastViewed !== this.state.lastViewed) { + return true; + } + + if (nextState.showMoreMessagesTop !== this.state.showMoreMessagesTop) { + return true; + } + + if (nextState.scrollType !== this.state.scrollType) { + return true; + } + + if (nextState.scrollPostId !== this.state.scrollPostId) { + return true; + } + + if (nextProps.channel.id !== this.props.channel.id) { + return true; + } + + if (!Utils.areObjectsEqual(nextState.currentUser, this.state.currentUser)) { + return true; + } + + if (!Utils.areObjectsEqual(nextState.postList, this.state.postList)) { + return true; + } + + if (!Utils.areObjectsEqual(nextState.profiles, this.state.profiles)) { + return true; + } + + return false; + } + + render() { + let content; + if (this.state.postList == null) { + content = ( + <LoadingScreen + position='absolute' + key='loading' + /> + ); + } else { + content = ( + <PostList + postList={this.state.postList} + profiles={this.state.profiles} + channel={this.state.channel} + currentUser={this.state.currentUser} + showMoreMessagesTop={!this.state.atTop} + scrollType={this.state.scrollType} + scrollPostId={this.state.scrollPostId} + postListScrolled={this.onPostListScroll} + displayNameType={this.state.displayNameType} + displayPostsInCenter={this.state.displayPostsInCenter} + compactDisplay={this.state.compactDisplay} + /> + ); + } + + let activeClass = ''; + if (!this.props.active) { + activeClass = 'inactive'; + } + + return ( + <div className={activeClass}> + {content} + </div> + ); + } +} + +PostViewController.propTypes = { + channel: React.PropTypes.object, + active: React.PropTypes.bool +}; |