summaryrefslogtreecommitdiffstats
path: root/webapp/components/post_view
diff options
context:
space:
mode:
authorJoram Wilander <jwawilander@gmail.com>2016-05-27 16:01:28 -0400
committerChristopher Speller <crspeller@gmail.com>2016-05-27 16:01:28 -0400
commit6399a94ce221be3d15e7132654c28cd953075ec6 (patch)
tree4b1927fdd8374e8bd3cb809ecb720f2689043358 /webapp/components/post_view
parentca9f348be6bf62fc888df9a710c9af155872528e (diff)
downloadchat-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')
-rw-r--r--webapp/components/post_view/components/floating_timestamp.jsx54
-rw-r--r--webapp/components/post_view/components/pending_post_options.jsx92
-rw-r--r--webapp/components/post_view/components/post.jsx216
-rw-r--r--webapp/components/post_view/components/post_attachment.jsx324
-rw-r--r--webapp/components/post_view/components/post_attachment_list.jsx30
-rw-r--r--webapp/components/post_view/components/post_attachment_oembed.jsx108
-rw-r--r--webapp/components/post_view/components/post_body.jsx186
-rw-r--r--webapp/components/post_view/components/post_body_additional_content.jsx151
-rw-r--r--webapp/components/post_view/components/post_header.jsx84
-rw-r--r--webapp/components/post_view/components/post_image.jsx83
-rw-r--r--webapp/components/post_view/components/post_info.jsx257
-rw-r--r--webapp/components/post_view/components/post_list.jsx524
-rw-r--r--webapp/components/post_view/components/providers.json376
-rw-r--r--webapp/components/post_view/components/scroll_to_bottom_arrows.jsx37
-rw-r--r--webapp/components/post_view/post_focus_view_controller.jsx128
-rw-r--r--webapp/components/post_view/post_view_cache.jsx85
-rw-r--r--webapp/components/post_view/post_view_controller.jsx264
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
+};