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
Этот коммит содержится в:
Joram Wilander
2016-05-27 16:01:28 -04:00
коммит произвёл Christopher Speller
родитель ca9f348be6
Коммит 6399a94ce2
27 изменённых файлов: 646 добавлений и 560 удалений

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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);

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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
}
]

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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>
);
}
}

Просмотреть файл

@@ -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>
);
}
}

Просмотреть файл

@@ -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
};