diff options
Diffstat (limited to 'web/react')
46 files changed, 837 insertions, 133 deletions
diff --git a/web/react/.eslintignore b/web/react/.eslintignore new file mode 100644 index 000000000..5e8e7e0b6 --- /dev/null +++ b/web/react/.eslintignore @@ -0,0 +1 @@ +**/*.json diff --git a/web/react/.eslintrc b/web/react/.eslintrc index d78068882..d4d28e863 100644 --- a/web/react/.eslintrc +++ b/web/react/.eslintrc @@ -6,8 +6,9 @@ "modules": true, "classes": true, "arrowFunctions": true, - "defaultParams": true, + "defaultParams": true }, + "parser": "babel-eslint", "plugins": [ "react" ], @@ -21,7 +22,8 @@ "React": false, "ReactDOM": false, "ReactBootstrap": false, - "Chart": false + "Chart": false, + "katex": false }, "rules": { "comma-dangle": [2, "never"], diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx index 27959ec7e..ab5686720 100644 --- a/web/react/components/access_history_modal.jsx +++ b/web/react/components/access_history_modal.jsx @@ -54,7 +54,7 @@ export default class AccessHistoryModal extends React.Component { } onAuditChange() { var newState = this.getStateFromStoresForAudits(); - if (!Utils.areStatesEqual(newState.audits, this.state.audits)) { + if (!Utils.areObjectsEqual(newState.audits, this.state.audits)) { this.setState(newState); } } diff --git a/web/react/components/activity_log_modal.jsx b/web/react/components/activity_log_modal.jsx index ef3077470..af423a601 100644 --- a/web/react/components/activity_log_modal.jsx +++ b/web/react/components/activity_log_modal.jsx @@ -73,7 +73,7 @@ export default class ActivityLogModal extends React.Component { } onListenerChange() { const newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(newState.sessions, this.state.sessions)) { + if (!Utils.areObjectsEqual(newState.sessions, this.state.sessions)) { this.setState(newState); } } diff --git a/web/react/components/admin_console/email_settings.jsx b/web/react/components/admin_console/email_settings.jsx index 40e00ff04..0cabf7f70 100644 --- a/web/react/components/admin_console/email_settings.jsx +++ b/web/react/components/admin_console/email_settings.jsx @@ -296,7 +296,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='feedbackName' ref='feedbackName' - placeholder='Ex: "Mattermost Notification", "System", "No-Reply"' + placeholder='E.g.: "Mattermost Notification", "System", "No-Reply"' defaultValue={this.props.config.EmailSettings.FeedbackName} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -318,7 +318,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='feedbackEmail' ref='feedbackEmail' - placeholder='Ex: "mattermost@yourcompany.com", "admin@yourcompany.com"' + placeholder='E.g.: "mattermost@yourcompany.com", "admin@yourcompany.com"' defaultValue={this.props.config.EmailSettings.FeedbackEmail} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -340,7 +340,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='SMTPUsername' ref='SMTPUsername' - placeholder='Ex: "admin@yourcompany.com", "AKIADTOVBGERKLCBV"' + placeholder='E.g.: "admin@yourcompany.com", "AKIADTOVBGERKLCBV"' defaultValue={this.props.config.EmailSettings.SMTPUsername} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -362,7 +362,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='SMTPPassword' ref='SMTPPassword' - placeholder='Ex: "yourpassword", "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY"' + placeholder='E.g.: "yourpassword", "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY"' defaultValue={this.props.config.EmailSettings.SMTPPassword} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -384,7 +384,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='SMTPServer' ref='SMTPServer' - placeholder='Ex: "smtp.yourcompany.com", "email-smtp.us-east-1.amazonaws.com"' + placeholder='E.g.: "smtp.yourcompany.com", "email-smtp.us-east-1.amazonaws.com"' defaultValue={this.props.config.EmailSettings.SMTPServer} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -406,7 +406,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='SMTPPort' ref='SMTPPort' - placeholder='Ex: "25", "465"' + placeholder='E.g.: "25", "465"' defaultValue={this.props.config.EmailSettings.SMTPPort} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -476,7 +476,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='InviteSalt' ref='InviteSalt' - placeholder='Ex "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9Yo"' + placeholder='E.g.: "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9Yo"' defaultValue={this.props.config.EmailSettings.InviteSalt} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} @@ -507,7 +507,7 @@ export default class EmailSettings extends React.Component { className='form-control' id='PasswordResetSalt' ref='PasswordResetSalt' - placeholder='Ex "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9Yo"' + placeholder='E.g.: "bjlSR4QqkXFBr7TP4oDzlfZmcNuH9Yo"' defaultValue={this.props.config.EmailSettings.PasswordResetSalt} onChange={this.handleChange} disabled={!this.state.sendEmailNotifications} diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index 895dc5fe4..a8d4ec100 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -39,11 +39,14 @@ export default class ChannelHeader extends React.Component { this.state = state; } getStateFromStores() { + const extraInfo = ChannelStore.getCurrentExtraInfo(); + return { channel: ChannelStore.getCurrent(), memberChannel: ChannelStore.getCurrentMember(), memberTeam: UserStore.getCurrentUser(), - users: ChannelStore.getCurrentExtraInfo().members, + users: extraInfo.members, + userCount: extraInfo.member_count, searchVisible: SearchStore.getSearchResults() !== null }; } @@ -63,7 +66,7 @@ export default class ChannelHeader extends React.Component { } onListenerChange() { const newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } $('.channel-header__info .description').popover({placement: 'bottom', trigger: 'hover', html: true, delay: {show: 500, hide: 500}}); @@ -373,6 +376,7 @@ export default class ChannelHeader extends React.Component { <th> <PopoverListMembers members={this.state.users} + memberCount={this.state.userCount} channelId={channel.id} /> </th> diff --git a/web/react/components/channel_invite_modal.jsx b/web/react/components/channel_invite_modal.jsx index 7c1032321..47bc50971 100644 --- a/web/react/components/channel_invite_modal.jsx +++ b/web/react/components/channel_invite_modal.jsx @@ -78,7 +78,7 @@ export default class ChannelInviteModal extends React.Component { } onListenerChange() { var newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(this.state, newState)) { + if (!Utils.areObjectsEqual(this.state, newState)) { this.setState(newState); } } diff --git a/web/react/components/channel_members_modal.jsx b/web/react/components/channel_members_modal.jsx index 2fa7ae8ff..5cf3511f4 100644 --- a/web/react/components/channel_members_modal.jsx +++ b/web/react/components/channel_members_modal.jsx @@ -91,7 +91,7 @@ export default class ChannelMembersModal extends React.Component { } onChange() { const newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(this.state, newState)) { + if (!Utils.areObjectsEqual(this.state, newState)) { this.setState(newState); } } diff --git a/web/react/components/channel_notifications.jsx b/web/react/components/channel_notifications.jsx index 43700bf36..f57fc12c5 100644 --- a/web/react/components/channel_notifications.jsx +++ b/web/react/components/channel_notifications.jsx @@ -69,7 +69,7 @@ export default class ChannelNotifications extends React.Component { newState.notifyLevel = notifyLevel; newState.markUnreadLevel = markUnreadLevel; - if (!Utils.areStatesEqual(this.state, newState)) { + if (!Utils.areObjectsEqual(this.state, newState)) { this.setState(newState); } } diff --git a/web/react/components/delete_post_modal.jsx b/web/react/components/delete_post_modal.jsx index 3a3dabce5..f3bead1c2 100644 --- a/web/react/components/delete_post_modal.jsx +++ b/web/react/components/delete_post_modal.jsx @@ -81,7 +81,7 @@ export default class DeletePostModal extends React.Component { } onListenerChange() { var newList = PostStore.getSelectedPost(); - if (!Utils.areStatesEqual(this.state.selectedList, newList)) { + if (!Utils.areObjectsEqual(this.state.selectedList, newList)) { this.setState({selectedList: newList}); } } diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx index e707e32f5..d6a30abf9 100644 --- a/web/react/components/file_attachment.jsx +++ b/web/react/components/file_attachment.jsx @@ -67,7 +67,7 @@ export default class FileAttachment extends React.Component { this.canSetState = false; } shouldComponentUpdate(nextProps, nextState) { - if (!utils.areStatesEqual(nextProps, this.props)) { + if (!utils.areObjectsEqual(nextProps, this.props)) { return true; } diff --git a/web/react/components/invite_member_modal.jsx b/web/react/components/invite_member_modal.jsx index c09477a69..3f6ad3358 100644 --- a/web/react/components/invite_member_modal.jsx +++ b/web/react/components/invite_member_modal.jsx @@ -31,7 +31,8 @@ export default class InviteMemberModal extends React.Component { firstNameErrors: {}, lastNameErrors: {}, emailEnabled: global.window.mm_config.SendEmailNotifications === 'true', - showConfirmModal: false + showConfirmModal: false, + isSendingEmails: false }; } @@ -89,10 +90,13 @@ export default class InviteMemberModal extends React.Component { var data = {}; data.invites = invites; + this.setState({isSendingEmails: true}); + Client.inviteMembers( data, () => { this.handleHide(false); + this.setState({isSendingEmails: false}); }, (err) => { if (err.message === 'This person is already on your team') { @@ -101,6 +105,8 @@ export default class InviteMemberModal extends React.Component { } else { this.setState({serverError: err.message}); } + + this.setState({isSendingEmails: false}); } ); } @@ -289,11 +295,6 @@ export default class InviteMemberModal extends React.Component { var content = null; var sendButton = null; - var sendButtonLabel = 'Send Invitation'; - if (this.state.inviteIds.length > 1) { - sendButtonLabel = 'Send Invitations'; - } - if (this.state.emailEnabled) { content = ( <div> @@ -309,14 +310,25 @@ export default class InviteMemberModal extends React.Component { </div> ); - sendButton = - ( - <button - onClick={this.handleSubmit} - type='button' - className='btn btn-primary' - >{sendButtonLabel}</button> + var sendButtonLabel = 'Send Invitation'; + if (this.state.isSendingEmails) { + sendButtonLabel = ( + <span><i className='fa fa-spinner fa-spin' />{' Sending'}</span> ); + } else if (this.state.inviteIds.length > 1) { + sendButtonLabel = 'Send Invitations'; + } + + sendButton = ( + <button + onClick={this.handleSubmit} + type='button' + className='btn btn-primary' + disabled={this.state.isSendingEmails} + > + {sendButtonLabel} + </button> + ); } else { var teamInviteLink = null; if (currentUser && TeamStore.getCurrent().type === 'O') { @@ -351,12 +363,13 @@ export default class InviteMemberModal extends React.Component { return ( <div> <Modal - className='modal-invite-member' + dialogClassName='modal-invite-member' show={this.state.show} onHide={this.handleHide.bind(this, true)} enforceFocus={!this.state.showConfirmModal} + backdrop={this.state.isSendingEmails ? 'static' : true} > - <Modal.Header closeButton={true}> + <Modal.Header closeButton={!this.state.isSendingEmails}> <Modal.Title>{'Invite New Member'}</Modal.Title> </Modal.Header> <Modal.Body ref='modalBody'> @@ -370,6 +383,7 @@ export default class InviteMemberModal extends React.Component { type='button' className='btn btn-default' onClick={this.handleHide.bind(this, true)} + disabled={this.state.isSendingEmails} > {'Cancel'} </button> diff --git a/web/react/components/login.jsx b/web/react/components/login.jsx index 2b9ce67ca..423ba9067 100644 --- a/web/react/components/login.jsx +++ b/web/react/components/login.jsx @@ -125,7 +125,7 @@ export default class Login extends React.Component { let emailSignup; if (global.window.mm_config.EnableSignUpWithEmail === 'true') { emailSignup = ( - <div> + <div className='signup__email-container'> <div className={'form-group' + errorClass}> <input autoFocus={focusEmail} @@ -206,7 +206,7 @@ export default class Login extends React.Component { href='/' className='signup-team-login' > - {'Sign up now'} + {'Create one now'} </a> </span> </div> diff --git a/web/react/components/more_channels.jsx b/web/react/components/more_channels.jsx index c4f831c2e..8a6dd84a4 100644 --- a/web/react/components/more_channels.jsx +++ b/web/react/components/more_channels.jsx @@ -46,7 +46,7 @@ export default class MoreChannels extends React.Component { } onListenerChange() { var newState = getStateFromStores(); - if (!utils.areStatesEqual(newState.channels, this.state.channels)) { + if (!utils.areObjectsEqual(newState.channels, this.state.channels)) { this.setState(newState); } } diff --git a/web/react/components/navbar_dropdown.jsx b/web/react/components/navbar_dropdown.jsx index 0b755f377..cf9db055d 100644 --- a/web/react/components/navbar_dropdown.jsx +++ b/web/react/components/navbar_dropdown.jsx @@ -70,7 +70,7 @@ export default class NavbarDropdown extends React.Component { } onListenerChange() { var newState = getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } diff --git a/web/react/components/new_channel_modal.jsx b/web/react/components/new_channel_modal.jsx index c0cea496f..2c044cd5d 100644 --- a/web/react/components/new_channel_modal.jsx +++ b/web/react/components/new_channel_modal.jsx @@ -115,7 +115,7 @@ export default class NewChannelModal extends React.Component { type='text' ref='display_name' className='form-control' - placeholder='Ex: "Bugs", "Marketing", "办公室恋情"' + placeholder='E.g.: "Bugs", "Marketing", "办公室恋情"' maxLength='22' value={this.props.channelData.displayName} autoFocus={true} diff --git a/web/react/components/notify_counts.jsx b/web/react/components/notify_counts.jsx index 54b9e4289..0a4f60989 100644 --- a/web/react/components/notify_counts.jsx +++ b/web/react/components/notify_counts.jsx @@ -39,7 +39,7 @@ export default class NotifyCounts extends React.Component { } onListenerChange() { var newState = getCountsStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { + if (!utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx index f3c0fa0b4..102bddcf5 100644 --- a/web/react/components/popover_list_members.jsx +++ b/web/react/components/popover_list_members.jsx @@ -69,8 +69,6 @@ export default class PopoverListMembers extends React.Component { render() { let popoverHtml = []; - let count = 0; - let countText = '-'; const members = this.props.members; const teamMembers = UserStore.getProfilesUsernameMap(); const currentUserId = UserStore.getCurrentId(); @@ -147,15 +145,22 @@ export default class PopoverListMembers extends React.Component { </div> </div> ); - count++; } }); + } - if (count > 20) { - countText = '20+'; - } else if (count > 0) { - countText = count.toString(); - } + let count = this.props.memberCount; + let countText = '-'; + + // fall back to checking the length of the member list if the count isn't set + if (!count && members) { + count = members.length; + } + + if (count > 20) { + countText = '20+'; + } else if (count > 0) { + countText = count.toString(); } return ( @@ -195,5 +200,6 @@ export default class PopoverListMembers extends React.Component { PopoverListMembers.propTypes = { members: React.PropTypes.array.isRequired, + memberCount: React.PropTypes.number, channelId: React.PropTypes.string.isRequired }; diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx index c3c5b3e0b..2b9586345 100644 --- a/web/react/components/post.jsx +++ b/web/react/components/post.jsx @@ -77,7 +77,7 @@ export default class Post extends React.Component { this.forceUpdate(); } shouldComponentUpdate(nextProps) { - if (!utils.areStatesEqual(nextProps.post, this.props.post)) { + if (!utils.areObjectsEqual(nextProps.post, this.props.post)) { return true; } diff --git a/web/react/components/post_attachment_oembed.jsx b/web/react/components/post_attachment_oembed.jsx new file mode 100644 index 000000000..f544dbc88 --- /dev/null +++ b/web/react/components/post_attachment_oembed.jsx @@ -0,0 +1,83 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +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) { + this.fetchData(nextProps.link); + } + + fetchData(link) { + if (!this.isLoading) { + this.isLoading = true; + return $.ajax({ + url: 'https://noembed.com/embed?nowrap=on&url=' + encodeURIComponent(link), + dataType: 'jsonp', + success: (result) => { + this.isLoading = false; + if (result.error) { + this.setState({data: {}}); + } else { + this.setState({data: result}); + } + }, + error: () => { + this.setState({data: {}}); + } + }); + } + } + + render() { + if ($.isEmptyObject(this.state.data)) { + return <div></div>; + } + + 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={this.state.data.url} + target='_blank' + > + {this.state.data.title} + </a> + </h1> + <div> + <div className={'attachment__body attachment__body--no_thumb'}> + <div + dangerouslySetInnerHTML={{__html: this.state.data.html}} + > + </div> + </div> + </div> + </div> + </div> + </div> + ); + } +} + +PostAttachmentOEmbed.propTypes = { + link: React.PropTypes.string.isRequired +}; diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 61a0c3e2d..617b4b36c 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -9,6 +9,8 @@ const TextFormatting = require('../utils/text_formatting.jsx'); const twemoji = require('twemoji'); const PostBodyAdditionalContent = require('./post_body_additional_content.jsx'); +const providers = require('./providers.json'); + export default class PostBody extends React.Component { constructor(props) { super(props); @@ -29,6 +31,7 @@ export default class PostBody extends React.Component { this.state = { links: linkData.links, message: linkData.text, + post: this.props.post, hasUserProfiles: profiles && Object.keys(profiles).length > 1 }; } @@ -52,6 +55,12 @@ export default class PostBody extends React.Component { twemoji.parse(ReactDOM.findDOMNode(this), {size: Constants.EMOJI_SIZE}); } + componentWillMount() { + if (this.props.post.filenames.length === 0 && this.state.links && this.state.links.length > 0) { + this.embed = this.createEmbed(this.state.links[0]); + } + } + componentDidMount() { this.parseEmojis(); @@ -76,19 +85,54 @@ export default class PostBody extends React.Component { componentWillReceiveProps(nextProps) { const linkData = Utils.extractLinks(nextProps.post.message); + if (this.props.post.filenames.length === 0 && this.state.links && this.state.links.length > 0) { + this.embed = this.createEmbed(linkData.links[0]); + } this.setState({links: linkData.links, message: linkData.text}); } createEmbed(link) { - let embed = this.createYoutubeEmbed(link); + const post = this.state.post; + + if (!link) { + if (post.type === 'oEmbed') { + post.props.oEmbedLink = ''; + post.type = ''; + } + return null; + } + + const trimmedLink = link.trim(); + + if (this.checkForOembedContent(trimmedLink)) { + post.props.oEmbedLink = trimmedLink; + post.type = 'oEmbed'; + this.setState({post}); + return ''; + } + + const embed = this.createYoutubeEmbed(link); if (embed != null) { return embed; } - embed = this.createGifEmbed(link); + if (link.substring(link.length - 4) === '.gif') { + return this.createGifEmbed(link, this.state.gifLoaded); + } + + return null; + } - return embed; + checkForOembedContent(link) { + for (let i = 0; i < providers.length; i++) { + for (let j = 0; j < providers[i].patterns.length; j++) { + if (link.match(providers[i].patterns[j])) { + return true; + } + } + } + return false; } loadGif(src) { @@ -101,18 +145,15 @@ export default class PostBody extends React.Component { const gif = new Image(); gif.onload = ( () => { + this.embed = this.createGifEmbed(src, true); this.setState({gifLoaded: true}); } ); gif.src = src; } - createGifEmbed(link) { - if (link.substring(link.length - 4) !== '.gif') { - return null; - } - - if (!this.state.gifLoaded) { + createGifEmbed(link, isLoaded) { + if (!isLoaded) { this.loadGif(link); return ( <img @@ -133,7 +174,7 @@ export default class PostBody extends React.Component { handleYoutubeTime(link) { const timeRegex = /[\\?&]t=([0-9hms]+)/; - const time = link.trim().match(timeRegex); + const time = link.match(timeRegex); if (!time || !time[1]) { return ''; } @@ -322,11 +363,6 @@ export default class PostBody extends React.Component { ); } - let embed; - if (filenames.length === 0 && this.state.links && this.state.links.length > 0) { - embed = this.createEmbed(this.state.links[0]); - } - let fileAttachmentHolder = ''; if (filenames && filenames.length > 0) { fileAttachmentHolder = ( @@ -354,10 +390,10 @@ export default class PostBody extends React.Component { /> </div> <PostBodyAdditionalContent - post={post} + post={this.state.post} /> {fileAttachmentHolder} - {embed} + {this.embed} </div> ); } diff --git a/web/react/components/post_body_additional_content.jsx b/web/react/components/post_body_additional_content.jsx index 8189ba2d3..0c2c44286 100644 --- a/web/react/components/post_body_additional_content.jsx +++ b/web/react/components/post_body_additional_content.jsx @@ -2,12 +2,14 @@ // See License.txt for license information. const PostAttachmentList = require('./post_attachment_list.jsx'); +const PostAttachmentOEmbed = require('./post_attachment_oembed.jsx'); export default class PostBodyAdditionalContent extends React.Component { constructor(props) { super(props); this.getSlackAttachment = this.getSlackAttachment.bind(this); + this.getOembedAttachment = this.getOembedAttachment.bind(this); this.getComponent = this.getComponent.bind(this); } @@ -25,17 +27,31 @@ export default class PostBodyAdditionalContent extends React.Component { ); } + getOembedAttachment() { + const link = this.props.post.props && this.props.post.props.oEmbedLink || ''; + return ( + <PostAttachmentOEmbed + key={'post_body_additional_content' + this.props.post.id} + link={link} + /> + ); + } + getComponent() { - switch (this.state.type) { + switch (this.props.post.type) { case 'slack_attachment': return this.getSlackAttachment(); + case 'oEmbed': + return this.getOembedAttachment(); + default: + return ''; } } render() { let content = []; - if (this.state.shouldRender) { + if (Boolean(this.props.post.type)) { const component = this.getComponent(); if (component) { diff --git a/web/react/components/posts_view.jsx b/web/react/components/posts_view.jsx index b782268fa..087ca1df2 100644 --- a/web/react/components/posts_view.jsx +++ b/web/react/components/posts_view.jsx @@ -104,11 +104,13 @@ export default class PostsView extends React.Component { // check if it's the last comment in a consecutive string of comments on the same post // it is the last comment if it is last post in the channel or the next post has a different root post - var isLastComment = Utils.isComment(post) && (i === 0 || posts[order[i - 1]].root_id !== post.root_id); + const isLastComment = Utils.isComment(post) && (i === 0 || posts[order[i - 1]].root_id !== post.root_id); - var postCtl = ( + const keyPrefix = post.id ? post.id : i; + + const postCtl = ( <Post - key={post.id + 'postKey'} + key={keyPrefix + 'postKey'} ref={post.id} sameUser={sameUser} sameRoot={sameRoot} @@ -240,7 +242,7 @@ export default class PostsView extends React.Component { if (this.props.messageSeparatorTime !== nextProps.messageSeparatorTime) { return true; } - if (!Utils.areStatesEqual(this.props.postList, nextProps.postList)) { + if (!Utils.areObjectsEqual(this.props.postList, nextProps.postList)) { return true; } diff --git a/web/react/components/posts_view_container.jsx b/web/react/components/posts_view_container.jsx index 8b92a26a7..2cb56cd47 100644 --- a/web/react/components/posts_view_container.jsx +++ b/web/react/components/posts_view_container.jsx @@ -225,7 +225,7 @@ export default class PostsViewContainer extends React.Component { } } shouldComponentUpdate(nextProps, nextState) { - if (Utils.areStatesEqual(this.state, nextState)) { + if (Utils.areObjectsEqual(this.state, nextState)) { return false; } diff --git a/web/react/components/providers.json b/web/react/components/providers.json new file mode 100644 index 000000000..5e4cbd656 --- /dev/null +++ b/web/react/components/providers.json @@ -0,0 +1,324 @@ +[ + { + "patterns": [ + "http://(?:www\\.)?xkcd\\.com/\\d+/?" + ], + "name": "XKCD" + }, + { + "patterns": [ + "https?://soundcloud.com/.*/.*" + ], + "name": "SoundCloud" + }, + { + "patterns": [ + "https?://(?:www\\.)?flickr\\.com/.*", + "https?://flic\\.kr/p/[a-zA-Z0-9]+" + ], + "name": "Flickr" + }, + { + "patterns": [ + "http://www\\.ted\\.com/talks/.+\\.html" + ], + "name": "TED" + }, + { + "patterns": [ + "http://(?:www\\.)?theverge\\.com/\\d{4}/\\d{1,2}/\\d{1,2}/\\d+/[^/]+/?$" + ], + "name": "The Verge" + }, + { + "patterns": [ + "http://.*\\.viddler\\.com/.*" + ], + "name": "Viddler" + }, + { + "patterns": [ + "https?://(?:www\\.)?avclub\\.com/article/[^/]+/?$" + ], + "name": "The AV Club" + }, + { + "patterns": [ + "https?://(?:www\\.)?wired\\.com/([^/]+/)?\\d+/\\d+/[^/]+/?$" + ], + "name": "Wired" + }, + { + "patterns": [ + "http://www\\.theonion\\.com/articles/[^/]+/?" + ], + "name": "The Onion" + }, + { + "patterns": [ + "http://yfrog\\.com/[0-9a-zA-Z]+/?$" + ], + "name": "YFrog" + }, + { + "patterns": [ + "http://www\\.duffelblog\\.com/\\d{4}/\\d{1,2}/[^/]+/?$" + ], + "name": "The Duffel Blog" + }, + { + "patterns": [ + "http://www\\.clickhole\\.com/article/[^/]+/?" + ], + "name": "Clickhole" + }, + { + "patterns": [ + "https?://(?:www.)?skitch.com/([^/]+)/[^/]+/.+", + "http://skit.ch/[^/]+" + ], + "name": "Skitch" + }, + { + "patterns": [ + "https?://(alpha|posts|photos)\\.app\\.net/.*" + ], + "name": "ADN" + }, + { + "patterns": [ + "https?://gist\\.github\\.com/(?:[-0-9a-zA-Z]+/)?([0-9a-fA-f]+)" + ], + "name": "Gist" + }, + { + "patterns": [ + "https?://www\\.(dropbox\\.com/s/.+\\.(?:jpg|png|gif))", + "https?://db\\.tt/[a-zA-Z0-9]+" + ], + "name": "Dropbox" + }, + { + "patterns": [ + "https?://[^\\.]+\\.wikipedia\\.org/wiki/(?!Talk:)[^#]+(?:#(.+))?" + ], + "name": "Wikipedia" + }, + { + "patterns": [ + "http://www.traileraddict.com/trailer/[^/]+/trailer" + ], + "name": "TrailerAddict" + }, + { + "patterns": [ + "http://lockerz\\.com/[sd]/\\d+" + ], + "name": "Lockerz" + }, + { + "patterns": [ + "http://gifuk\\.com/s/[0-9a-f]{16}" + ], + "name": "GIFUK" + }, + { + "patterns": [ + "http://trailers\\.apple\\.com/trailers/[^/]+/[^/]+" + ], + "name": "iTunes Movie Trailers" + }, + { + "patterns": [ + "http://gfycat\\.com/([a-zA-Z]+)" + ], + "name": "Gfycat" + }, + { + "patterns": [ + "http://bash\\.org/\\?(\\d+)" + ], + "name": "Bash.org" + }, + { + "patterns": [ + "http://arstechnica\\.com/[^/]+/\\d+/\\d+/[^/]+/?$" + ], + "name": "Ars Technica" + }, + { + "patterns": [ + "http://imgur\\.com/gallery/[0-9a-zA-Z]+" + ], + "name": "Imgur" + }, + { + "patterns": [ + "http://www\\.asciiartfarts\\.com/[0-9]+\\.html" + ], + "name": "ASCII Art Farts" + }, + { + "patterns": [ + "http://www\\.monoprice\\.com/products/product\\.asp\\?.*p_id=\\d+" + ], + "name": "Monoprice" + }, + { + "patterns": [ + "http://boingboing\\.net/\\d{4}/\\d{2}/\\d{2}/[^/]+\\.html" + ], + "name": "Boing Boing" + }, + { + "patterns": [ + "https?://github\\.com/([^/]+)/([^/]+)/commit/(.+)", + "http://git\\.io/[_0-9a-zA-Z]+" + ], + "name": "Github Commit" + }, + { + "patterns": [ + "https?://open\\.spotify\\.com/(track|album)/([0-9a-zA-Z]{22})" + ], + "name": "Spotify" + }, + { + "patterns": [ + "https?://path\\.com/p/([0-9a-zA-Z]+)$" + ], + "name": "Path" + }, + { + "patterns": [ + "http://www.funnyordie.com/videos/[^/]+/.+" + ], + "name": "Funny or Die" + }, + { + "patterns": [ + "http://(?:www\\.)?twitpic\\.com/([^/]+)" + ], + "name": "Twitpic" + }, + { + "patterns": [ + "https?://www\\.giantbomb\\.com/videos/[^/]+/\\d+-\\d+/?" + ], + "name": "GiantBomb" + }, + { + "patterns": [ + "http://(?:www\\.)?beeradvocate\\.com/beer/profile/\\d+/\\d+" + ], + "name": "Beer Advocate" + }, + { + "patterns": [ + "http://(?:www\\.)?imdb.com/title/(tt\\d+)" + ], + "name": "IMDB" + }, + { + "patterns": [ + "http://cl\\.ly/(?:image/)?[0-9a-zA-Z]+/?$" + ], + "name": "CloudApp" + }, + { + "patterns": [ + "http://clyp\\.it/.*" + ], + "name": "Clyp" + }, + { + "patterns": [ + "http://www\\.hulu\\.com/watch/.*" + ], + "name": "Hulu" + }, + { + "patterns": [ + "https?://(?:www|mobile\\.)?twitter\\.com/(?:#!/)?[^/]+/status(?:es)?/(\\d+)/?$", + "https?://t\\.co/[a-zA-Z0-9]+" + ], + "name": "Twitter" + }, + { + "patterns": [ + "https?://(?:www\\.)?vimeo\\.com/.+" + ], + "name": "Vimeo" + }, + { + "patterns": [ + "http://www\\.amazon\\.com/(?:.+/)?[gd]p/(?:product/)?(?:tags-on-product/)?([a-zA-Z0-9]+)", + "http://amzn\\.com/([^/]+)" + ], + "name": "Amazon" + }, + { + "patterns": [ + "http://qik\\.com/video/.*" + ], + "name": "Qik" + }, + { + "patterns": [ + "http://www\\.rdio\\.com/artist/[^/]+/album/[^/]+/?", + "http://www\\.rdio\\.com/artist/[^/]+/album/[^/]+/track/[^/]+/?", + "http://www\\.rdio\\.com/people/[^/]+/playlists/\\d+/[^/]+" + ], + "name": "Rdio" + }, + { + "patterns": [ + "http://www\\.slideshare\\.net/.*/.*" + ], + "name": "SlideShare" + }, + { + "patterns": [ + "http://imgur\\.com/([0-9a-zA-Z]+)$" + ], + "name": "Imgur" + }, + { + "patterns": [ + "https?://instagr(?:\\.am|am\\.com)/p/.+" + ], + "name": "Instagram" + }, + { + "patterns": [ + "http://www\\.twitlonger\\.com/show/[a-zA-Z0-9]+", + "http://tl\\.gd/[^/]+" + ], + "name": "Twitlonger" + }, + { + "patterns": [ + "https?://vine.co/v/[a-zA-Z0-9]+" + ], + "name": "Vine" + }, + { + "patterns": [ + "http://www\\.urbandictionary\\.com/define\\.php\\?term=.+" + ], + "name": "Urban Dictionary" + }, + { + "patterns": [ + "http://picplz\\.com/user/[^/]+/pic/[^/]+" + ], + "name": "Picplz" + }, + { + "patterns": [ + "https?://(?:www\\.)?twitter\\.com/(?:#!/)?[^/]+/status(?:es)?/(\\d+)/photo/\\d+(?:/large|/)?$", + "https?://pic\\.twitter\\.com/.+" + ], + "name": "Twitter" + } +]
\ No newline at end of file diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx index 8c6324c72..58cc1cac7 100644 --- a/web/react/components/rhs_comment.jsx +++ b/web/react/components/rhs_comment.jsx @@ -61,7 +61,7 @@ export default class RhsComment extends React.Component { this.parseEmojis(); } shouldComponentUpdate(nextProps) { - if (!Utils.areStatesEqual(nextProps.post, this.props.post)) { + if (!Utils.areObjectsEqual(nextProps.post, this.props.post)) { return true; } diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx index e3b023841..69de5d523 100644 --- a/web/react/components/rhs_root_post.jsx +++ b/web/react/components/rhs_root_post.jsx @@ -26,7 +26,7 @@ export default class RhsRootPost extends React.Component { this.parseEmojis(); } shouldComponentUpdate(nextProps) { - if (!utils.areStatesEqual(nextProps.post, this.props.post)) { + if (!utils.areObjectsEqual(nextProps.post, this.props.post)) { return true; } diff --git a/web/react/components/rhs_thread.jsx b/web/react/components/rhs_thread.jsx index fe57bed28..7c11de7cf 100644 --- a/web/react/components/rhs_thread.jsx +++ b/web/react/components/rhs_thread.jsx @@ -82,7 +82,7 @@ export default class RhsThread extends React.Component { } onChange() { var newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } @@ -112,7 +112,7 @@ export default class RhsThread extends React.Component { } var newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index 90865475b..0f749f2cf 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -46,7 +46,7 @@ export default class SearchBar extends React.Component { onListenerChange(doSearch, isMentionSearch) { if (this.mounted) { var newState = this.getSearchTermStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { + if (!utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } if (doSearch) { diff --git a/web/react/components/search_results.jsx b/web/react/components/search_results.jsx index b56a7b006..2f0068908 100644 --- a/web/react/components/search_results.jsx +++ b/web/react/components/search_results.jsx @@ -55,7 +55,7 @@ export default class SearchResults extends React.Component { onChange() { if (this.mounted) { var newState = getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } diff --git a/web/react/components/settings_sidebar.jsx b/web/react/components/settings_sidebar.jsx index 68d9cea48..4af46c35a 100644 --- a/web/react/components/settings_sidebar.jsx +++ b/web/react/components/settings_sidebar.jsx @@ -1,14 +1,10 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var utils = require('../utils/utils.jsx'); export default class SettingsSidebar extends React.Component { componentDidUpdate() { $('.settings-modal').find('.modal-body').scrollTop(0); $('.settings-modal').find('.modal-body').perfectScrollbar('update'); - if (utils.isSafari()) { - $('.settings-modal .settings-links .nav').addClass('absolute'); - } } constructor(props) { super(props); diff --git a/web/react/components/sidebar.jsx b/web/react/components/sidebar.jsx index 0b1abe4fe..542f433f3 100644 --- a/web/react/components/sidebar.jsx +++ b/web/react/components/sidebar.jsx @@ -106,6 +106,8 @@ export default class Sidebar extends React.Component { const currentChannelId = ChannelStore.getCurrentId(); const channels = Object.assign([], ChannelStore.getAll()); + channels.sort((a, b) => a.display_name.localeCompare(b.display_name)); + const publicChannels = channels.filter((channel) => channel.type === Constants.OPEN_CHANNEL); const privateChannels = channels.filter((channel) => channel.type === Constants.PRIVATE_CHANNEL); const directChannels = channels.filter((channel) => channel.type === Constants.DM_CHANNEL); @@ -173,7 +175,7 @@ export default class Sidebar extends React.Component { window.addEventListener('resize', this.handleResize); } shouldComponentUpdate(nextProps, nextState) { - if (!Utils.areStatesEqual(nextState, this.state)) { + if (!Utils.areObjectsEqual(nextState, this.state)) { return true; } return false; @@ -205,10 +207,7 @@ export default class Sidebar extends React.Component { } } onChange() { - var newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { - this.setState(newState); - } + this.setState(this.getStateFromStores()); } updateTitle() { const channel = ChannelStore.getCurrent(); diff --git a/web/react/components/sidebar_right.jsx b/web/react/components/sidebar_right.jsx index e2ef60959..ab558ad0f 100644 --- a/web/react/components/sidebar_right.jsx +++ b/web/react/components/sidebar_right.jsx @@ -66,13 +66,13 @@ export default class SidebarRight extends React.Component { onSelectedChange(fromSearch) { var newState = getStateFromStores(fromSearch); newState.from_search = fromSearch; - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } onSearchChange() { var newState = getStateFromStores(); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } diff --git a/web/react/components/team_members.jsx b/web/react/components/team_members.jsx index ac1ebf52d..afe7f46ec 100644 --- a/web/react/components/team_members.jsx +++ b/web/react/components/team_members.jsx @@ -59,7 +59,7 @@ export default class TeamMembers extends React.Component { onChange() { var newState = getStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { + if (!utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } diff --git a/web/react/components/team_settings.jsx b/web/react/components/team_settings.jsx index 09674f1ef..862f3c528 100644 --- a/web/react/components/team_settings.jsx +++ b/web/react/components/team_settings.jsx @@ -23,7 +23,7 @@ export default class TeamSettings extends React.Component { } onChange() { var team = TeamStore.getCurrent(); - if (!Utils.areStatesEqual(this.state.team, team)) { + if (!Utils.areObjectsEqual(this.state.team, team)) { this.setState({team}); } } diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx index 707033d8f..e6530b941 100644 --- a/web/react/components/textbox.jsx +++ b/web/react/components/textbox.jsx @@ -6,6 +6,7 @@ const SearchStore = require('../stores/search_store.jsx'); const CommandList = require('./command_list.jsx'); const ErrorStore = require('../stores/error_store.jsx'); +const TextFormatting = require('../utils/text_formatting.jsx'); const Utils = require('../utils/utils.jsx'); const Constants = require('../utils/constants.jsx'); const ActionTypes = Constants.ActionTypes; @@ -30,6 +31,7 @@ export default class Textbox extends React.Component { this.handleFocus = this.handleFocus.bind(this); this.handleBlur = this.handleBlur.bind(this); this.handlePaste = this.handlePaste.bind(this); + this.showPreview = this.showPreview.bind(this); this.state = { mentionText: '-1', @@ -118,7 +120,8 @@ export default class Textbox extends React.Component { } handleChange() { - this.props.onUserInput(ReactDOM.findDOMNode(this.refs.message).value); + const text = ReactDOM.findDOMNode(this.refs.message).value; + this.props.onUserInput(text); } handleKeyPress(e) { @@ -250,10 +253,16 @@ export default class Textbox extends React.Component { $(e).css({height: 'auto', 'overflow-y': 'hidden'}).height(e.scrollHeight - mod); $(w).css({height: 'auto'}).height(e.scrollHeight + 2); $(w).closest('.post-body__cell').removeClass('scroll'); + if (this.state.preview) { + $(ReactDOM.findDOMNode(this.refs.preview)).css({height: 'auto', 'overflow-y': 'auto'}).height(e.scrollHeight - mod); + } } else { - $(e).css({height: 'auto', 'overflow-y': 'scroll'}).height(167); - $(w).css({height: 'auto'}).height(167); + $(e).css({height: 'auto', 'overflow-y': 'scroll'}).height(167 - mod); + $(w).css({height: 'auto'}).height(163); $(w).closest('.post-body__cell').addClass('scroll'); + if (this.state.preview) { + $(ReactDOM.findDOMNode(this.refs.preview)).css({height: 'auto', 'overflow-y': 'scroll'}).height(163); + } } if (prevHeight !== $(e).height() && this.props.onHeightChange) { @@ -279,7 +288,16 @@ export default class Textbox extends React.Component { this.doProcessMentions = true; } + showPreview(e) { + e.preventDefault(); + e.target.blur(); + this.setState({preview: !this.state.preview}); + this.resize(); + } + render() { + const previewLinkVisible = this.props.messageText.length > 0; + return ( <div ref='wrapper' @@ -308,7 +326,22 @@ export default class Textbox extends React.Component { onFocus={this.handleFocus} onBlur={this.handleBlur} onPaste={this.handlePaste} + style={{visibility: this.state.preview ? 'hidden' : 'visible'}} /> + <div + ref='preview' + className='form-control custom-textarea textbox-preview-area' + style={{display: this.state.preview ? 'block' : 'none'}} + dangerouslySetInnerHTML={{__html: this.state.preview ? TextFormatting.formatText(this.props.messageText) : ''}} + > + </div> + <a + style={{visibility: previewLinkVisible ? 'visible' : 'hidden'}} + onClick={this.showPreview} + className='textbox-preview-link' + > + {this.state.preview ? 'Edit message' : 'Preview'} + </a> </div> ); } diff --git a/web/react/components/tutorial/tutorial_intro_screens.jsx b/web/react/components/tutorial/tutorial_intro_screens.jsx index 66ca556c6..3afc5145d 100644 --- a/web/react/components/tutorial/tutorial_intro_screens.jsx +++ b/web/react/components/tutorial/tutorial_intro_screens.jsx @@ -41,6 +41,11 @@ export default class TutorialIntroScreens extends React.Component { componentDidMount() { $('.tutorials__scroll').perfectScrollbar(); } + skipTutorial(e) { + e.preventDefault(); + const preference = PreferenceStore.setPreference(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), '999'); + AsyncClient.savePreferences([preference]); + } createScreen() { switch (this.state.currentScreen) { case 0: @@ -176,6 +181,13 @@ export default class TutorialIntroScreens extends React.Component { > {'Next'} </button> + <a + className='tutorial-skip' + href='#' + onClick={this.skipTutorial} + > + {'Skip tutorial'} + </a> </div> </div> </div> diff --git a/web/react/components/user_profile.jsx b/web/react/components/user_profile.jsx index eb0a8f0ca..a2523ef68 100644 --- a/web/react/components/user_profile.jsx +++ b/web/react/components/user_profile.jsx @@ -29,7 +29,7 @@ export default class UserProfile extends React.Component { return {profile: {id: '0', username: '...'}}; } - return {profile: profile}; + return {profile}; } componentDidMount() { UserStore.addChangeListener(this.onChange); @@ -43,7 +43,7 @@ export default class UserProfile extends React.Component { onChange(userId) { if (!userId || userId === this.props.userId) { var newState = this.getStateFromStores(this.props.userId); - if (!Utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } diff --git a/web/react/components/user_settings/user_settings.jsx b/web/react/components/user_settings/user_settings.jsx index e089ce973..40825ba93 100644 --- a/web/react/components/user_settings/user_settings.jsx +++ b/web/react/components/user_settings/user_settings.jsx @@ -36,7 +36,7 @@ export default class UserSettings extends React.Component { onListenerChange() { var user = UserStore.getCurrentUser(); - if (!utils.areStatesEqual(this.state.user, user)) { + if (!utils.areObjectsEqual(this.state.user, user)) { this.setState({user}); } } diff --git a/web/react/components/user_settings/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx index d73b5f476..029a1af5e 100644 --- a/web/react/components/user_settings/user_settings_appearance.jsx +++ b/web/react/components/user_settings/user_settings_appearance.jsx @@ -1,13 +1,15 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore = require('../../stores/user_store.jsx'); -var Client = require('../../utils/client.jsx'); -var Utils = require('../../utils/utils.jsx'); - const CustomThemeChooser = require('./custom_theme_chooser.jsx'); const PremadeThemeChooser = require('./premade_theme_chooser.jsx'); + +const UserStore = require('../../stores/user_store.jsx'); + const AppDispatcher = require('../../dispatcher/app_dispatcher.jsx'); +const Client = require('../../utils/client.jsx'); +const Utils = require('../../utils/utils.jsx'); + const Constants = require('../../utils/constants.jsx'); const ActionTypes = Constants.ActionTypes; @@ -66,7 +68,7 @@ export default class UserSettingsAppearance extends React.Component { onChange() { const newState = this.getStateFromStores(); - if (!Utils.areStatesEqual(this.state, newState)) { + if (!Utils.areObjectsEqual(this.state, newState)) { this.setState(newState); } diff --git a/web/react/components/user_settings/user_settings_notifications.jsx b/web/react/components/user_settings/user_settings_notifications.jsx index c6f47804f..c958bf5bc 100644 --- a/web/react/components/user_settings/user_settings_notifications.jsx +++ b/web/react/components/user_settings/user_settings_notifications.jsx @@ -1,16 +1,18 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -var UserStore = require('../../stores/user_store.jsx'); -var SettingItemMin = require('../setting_item_min.jsx'); -var SettingItemMax = require('../setting_item_max.jsx'); -var client = require('../../utils/client.jsx'); -var AsyncClient = require('../../utils/async_client.jsx'); -var utils = require('../../utils/utils.jsx'); +const SettingItemMin = require('../setting_item_min.jsx'); +const SettingItemMax = require('../setting_item_max.jsx'); + +const UserStore = require('../../stores/user_store.jsx'); + +const Client = require('../../utils/client.jsx'); +const AsyncClient = require('../../utils/async_client.jsx'); +const Utils = require('../../utils/utils.jsx'); function getNotificationsStateFromStores() { var user = UserStore.getCurrentUser(); - var soundNeeded = !utils.isBrowserFirefox(); + var soundNeeded = !Utils.isBrowserFirefox(); var sound = 'true'; if (user.notify_props && user.notify_props.desktop_sound) { @@ -116,7 +118,7 @@ export default class NotificationsTab extends React.Component { data.all = this.state.allKey.toString(); data.channel = this.state.channelKey.toString(); - client.updateUserNotifyProps(data, + Client.updateUserNotifyProps(data, function success() { this.props.updateSection(''); AsyncClient.getMe(); @@ -138,7 +140,7 @@ export default class NotificationsTab extends React.Component { } onListenerChange() { var newState = getNotificationsStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { + if (!Utils.areObjectsEqual(newState, this.state)) { this.setState(newState); } } diff --git a/web/react/package.json b/web/react/package.json index 9af6f5880..de59b48ac 100644 --- a/web/react/package.json +++ b/web/react/package.json @@ -18,7 +18,8 @@ "uglify-js": "2.4.24", "watchify": "3.4.0", "eslint": "1.6.0", - "eslint-plugin-react": "3.5.1" + "eslint-plugin-react": "3.5.1", + "babel-eslint": "4.1.4" }, "scripts": { "check": "", diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx index b173c9ca0..40b64b34b 100644 --- a/web/react/stores/user_store.jsx +++ b/web/react/stores/user_store.jsx @@ -164,6 +164,10 @@ class UserStoreClass extends EventEmitter { } getProfile(userId) { + if (userId === this.getCurrentId()) { + return this.getCurrentUser(); + } + return this.getProfiles()[userId]; } diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 946f93078..4d1a35d19 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -69,6 +69,15 @@ class MattermostMarkdownRenderer extends marked.Renderer { usedLanguage = 'xml'; } + if (usedLanguage && (usedLanguage === 'tex' || usedLanguage === 'latex')) { + try { + var html = katex.renderToString(TextFormatting.sanitizeHtml(code), {throwOnError: false, displayMode: true}); + return '<div class="post-body--code tex">' + html + '</div>'; + } catch (e) { + return '<div class="post-body--code">' + TextFormatting.sanitizeHtml(code) + '</div>'; + } + } + if (!usedLanguage || highlightJs.listLanguages().indexOf(usedLanguage) < 0) { let parsed = super.code(code, usedLanguage); return '<div class="post-body--code"><code class="hljs">' + TextFormatting.sanitizeHtml($(parsed).text()) + '</code></div>'; diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 705d85cf6..7f1d7175d 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -259,30 +259,73 @@ function autolinkHashtags(text, tokens) { return output.replace(/(^|\W)(#[a-zA-Z][a-zA-Z0-9.\-_]*)\b/g, replaceHashtagWithToken); } -function highlightSearchTerm(text, tokens, searchTerm) { - let output = text; +const puncStart = /^[.,()&$!\[\]{}':;\\]+/; +const puncEnd = /[.,()&$#!\[\]{}':;\\]+$/; - var newTokens = new Map(); - for (const [alias, token] of tokens) { - if (token.originalText.indexOf(searchTerm.replace(/\*$/, '')) > -1) { - const index = tokens.size + newTokens.size; - const newAlias = `MM_SEARCHTERM${index}`; +function parseSearchTerms(searchTerm) { + let terms = []; - newTokens.set(newAlias, { - value: `<span class='search-highlight'>${alias}</span>`, - originalText: token.originalText - }); + let termString = searchTerm; - output = output.replace(alias, newAlias); + while (termString) { + let captured; + + // check for a quoted string + captured = (/^"(.*?)"/).exec(termString); + if (captured) { + termString = termString.substring(captured[0].length); + terms.push(captured[1]); + continue; + } + + // check for a search flag (and don't add it to terms) + captured = (/^(?:in|from|channel): ?\S+/).exec(termString); + if (captured) { + termString = termString.substring(captured[0].length); + continue; + } + + // capture any plain text up until the next quote or search flag + captured = (/^.+?(?=\bin|\bfrom|\bchannel|"|$)/).exec(termString); + if (captured) { + termString = termString.substring(captured[0].length); + + // break the text up into words based on how the server splits them in SqlPostStore.SearchPosts and then discard empty terms + terms.push(...captured[0].split(/[ <>+\-\(\)\~\@]/).filter((term) => !!term)); + continue; } + + // we should never reach this point since at least one of the regexes should match something in the remaining text + throw new Error('Infinite loop in search term parsing: ' + termString); } - // the new tokens are stashed in a separate map since we can't add objects to a map during iteration - for (const newToken of newTokens) { - tokens.set(newToken[0], newToken[1]); + // remove punctuation from each term + terms = terms.map((term) => term.replace(puncStart, '').replace(puncEnd, '')); + + return terms; +} + +function convertSearchTermToRegex(term) { + let pattern; + if (term.endsWith('*')) { + pattern = '\\b' + escapeRegex(term.substring(0, term.length - 1)); + } else { + pattern = '\\b' + escapeRegex(term) + '\\b'; } - function replaceSearchTermWithToken(fullMatch, prefix, word) { + return new RegExp(pattern, 'gi'); +} + +function highlightSearchTerm(text, tokens, searchTerm) { + const terms = parseSearchTerms(searchTerm); + + if (terms.length === 0) { + return text; + } + + let output = text; + + function replaceSearchTermWithToken(word) { const index = tokens.size; const alias = `MM_SEARCHTERM${index}`; @@ -291,10 +334,35 @@ function highlightSearchTerm(text, tokens, searchTerm) { originalText: word }); - return prefix + alias; + return alias; } - return output.replace(new RegExp(`()(${escapeRegex(searchTerm)})`, 'gi'), replaceSearchTermWithToken); + for (const term of terms) { + // highlight existing tokens matching search terms + var newTokens = new Map(); + for (const [alias, token] of tokens) { + if (token.originalText === term.replace(/\*$/, '')) { + const index = tokens.size + newTokens.size; + const newAlias = `MM_SEARCHTERM${index}`; + + newTokens.set(newAlias, { + value: `<span class='search-highlight'>${alias}</span>`, + originalText: token.originalText + }); + + output = output.replace(alias, newAlias); + } + } + + // the new tokens are stashed in a separate map since we can't add objects to a map during iteration + for (const newToken of newTokens) { + tokens.set(newToken[0], newToken[1]); + } + + output = output.replace(convertSearchTermToRegex(term), replaceSearchTermWithToken); + } + + return output; } function replaceTokens(text, tokens) { diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 38f91b35f..6f3924829 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -311,8 +311,98 @@ export function escapeRegExp(string) { return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1'); } -export function areStatesEqual(state1, state2) { - return JSON.stringify(state1) === JSON.stringify(state2); +// Taken from http://stackoverflow.com/questions/1068834/object-comparison-in-javascript and modified slightly +export function areObjectsEqual(x, y) { + let p; + const leftChain = []; + const rightChain = []; + + // Remember that NaN === NaN returns false + // and isNaN(undefined) returns true + if (isNaN(x) && isNaN(y) && typeof x === 'number' && typeof y === 'number') { + return true; + } + + // Compare primitives and functions. + // Check if both arguments link to the same object. + // Especially useful on step when comparing prototypes + if (x === y) { + return true; + } + + // Works in case when functions are created in constructor. + // Comparing dates is a common scenario. Another built-ins? + // We can even handle functions passed across iframes + if ((typeof x === 'function' && typeof y === 'function') || + (x instanceof Date && y instanceof Date) || + (x instanceof RegExp && y instanceof RegExp) || + (x instanceof String && y instanceof String) || + (x instanceof Number && y instanceof Number)) { + return x.toString() === y.toString(); + } + + // At last checking prototypes as good a we can + if (!(x instanceof Object && y instanceof Object)) { + return false; + } + + if (x.isPrototypeOf(y) || y.isPrototypeOf(x)) { + return false; + } + + if (x.constructor !== y.constructor) { + return false; + } + + if (x.prototype !== y.prototype) { + return false; + } + + // Check for infinitive linking loops + if (leftChain.indexOf(x) > -1 || rightChain.indexOf(y) > -1) { + return false; + } + + // Quick checking of one object beeing a subset of another. + for (p in y) { + if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { + return false; + } else if (typeof y[p] !== typeof x[p]) { + return false; + } + } + + for (p in x) { + if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { + return false; + } else if (typeof y[p] !== typeof x[p]) { + return false; + } + + switch (typeof (x[p])) { + case 'object': + case 'function': + + leftChain.push(x); + rightChain.push(y); + + if (!areObjectsEqual(x[p], y[p])) { + return false; + } + + leftChain.pop(); + rightChain.pop(); + break; + + default: + if (x[p] !== y[p]) { + return false; + } + break; + } + } + + return true; } export function replaceHtmlEntities(text) { |