diff options
Diffstat (limited to 'web/react')
-rw-r--r-- | web/react/components/edit_channel_purpose_modal.jsx | 9 | ||||
-rw-r--r-- | web/react/components/post_attachment.jsx | 295 | ||||
-rw-r--r-- | web/react/components/post_attachment_list.jsx | 32 | ||||
-rw-r--r-- | web/react/components/post_body.jsx | 4 | ||||
-rw-r--r-- | web/react/components/post_body_additional_content.jsx | 56 | ||||
-rw-r--r-- | web/react/components/search_autocomplete.jsx | 157 | ||||
-rw-r--r-- | web/react/components/tutorial/tutorial_intro_screens.jsx | 54 | ||||
-rw-r--r-- | web/react/components/user_profile.jsx | 2 | ||||
-rw-r--r-- | web/react/components/user_settings/user_settings_general.jsx | 90 | ||||
-rw-r--r-- | web/react/stores/browser_store.jsx | 37 | ||||
-rw-r--r-- | web/react/stores/user_store.jsx | 9 | ||||
-rw-r--r-- | web/react/utils/client.jsx | 1 | ||||
-rw-r--r-- | web/react/utils/highlight.jsx | 51 | ||||
-rw-r--r-- | web/react/utils/markdown.jsx | 351 | ||||
-rw-r--r-- | web/react/utils/utils.jsx | 14 |
15 files changed, 999 insertions, 163 deletions
diff --git a/web/react/components/edit_channel_purpose_modal.jsx b/web/react/components/edit_channel_purpose_modal.jsx index 4cb96a3ff..65e8183de 100644 --- a/web/react/components/edit_channel_purpose_modal.jsx +++ b/web/react/components/edit_channel_purpose_modal.jsx @@ -3,6 +3,8 @@ const AsyncClient = require('../utils/async_client.jsx'); const Client = require('../utils/client.jsx'); +const Utils = require('../utils/utils.jsx'); + const Modal = ReactBootstrap.Modal; export default class EditChannelPurposeModal extends React.Component { @@ -75,11 +77,6 @@ export default class EditChannelPurposeModal extends React.Component { title = <span>{'Edit Purpose for '}<span className='name'>{this.props.channel.display_name}</span></span>; } - let channelTerm = 'Channel'; - if (this.props.channel.channelType === 'P') { - channelTerm = 'Group'; - } - return ( <Modal className='modal-edit-channel-purpose' @@ -93,7 +90,7 @@ export default class EditChannelPurposeModal extends React.Component { </Modal.Title> </Modal.Header> <Modal.Body> - <p>{`Describe how this ${channelTerm} should be used.`}</p> + <p>{`Describe how this ${Utils.getChannelTerm(this.props.channel.channelType)} should be used.`}</p> <textarea ref='purpose' className='form-control no-resize' diff --git a/web/react/components/post_attachment.jsx b/web/react/components/post_attachment.jsx new file mode 100644 index 000000000..2d6b47f03 --- /dev/null +++ b/web/react/components/post_attachment.jsx @@ -0,0 +1,295 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const TextFormatting = require('../utils/text_formatting.jsx'); + +export default 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="#">▲ collapse text</a>' : ''); + const collapsedText = shouldCollapse ? this.getCollapsedText() : text; + + return { + shouldCollapse, + collapsedText, + uncollapsedText, + text: shouldCollapse ? collapsedText : uncollapsedText, + collapsed: shouldCollapse + }; + } + + toggleCollapseState(e) { + e.preventDefault(); + + let state = this.state; + state.text = state.collapsed ? state.uncollapsedText : state.collapsedText; + state.collapsed = !state.collapsed; + this.setState(state); + } + + shouldCollapse() { + return (this.props.attachment.text.match(/\n/g) || []).length >= 5 || this.props.attachment.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="#">▼ read more</a>'; + } + + getFieldsTable() { + const fields = this.props.attachment.fields; + if (!fields || !fields.length) { + return ''; + } + + const compactTable = fields.filter((field) => field.short).length > 0; + let tHead; + let tBody; + + if (compactTable) { + let headerCols = []; + let bodyCols = []; + + fields.forEach((field, i) => { + headerCols.push( + <th + className='attachment___field-caption' + key={'attachment__field-caption-' + i} + > + {field.title} + </th> + ); + bodyCols.push( + <td + className='attachment___field' + key={'attachment__field-' + i} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(field.value || '')}} + > + </td> + ); + }); + + tHead = ( + <tr> + {headerCols} + </tr> + ); + tBody = ( + <tr> + {bodyCols} + </tr> + ); + } else { + tBody = []; + + fields.forEach((field, i) => { + tBody.push( + <tr key={'attachment__field-' + i}> + <td + className='attachment___field-caption' + > + {field.title} + </td> + <td + className='attachment___field' + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(field.value || '')}} + > + </td> + </tr> + ); + }); + } + + return ( + <table + className='attachment___fields' + > + <thead> + {tHead} + </thead> + <tbody> + {tBody} + </tbody> + </table> + ); + } + + 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' + > + {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' + > + {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 = { + attachment: React.PropTypes.object.isRequired +};
\ No newline at end of file diff --git a/web/react/components/post_attachment_list.jsx b/web/react/components/post_attachment_list.jsx new file mode 100644 index 000000000..03b866656 --- /dev/null +++ b/web/react/components/post_attachment_list.jsx @@ -0,0 +1,32 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const PostAttachment = require('./post_attachment.jsx'); + +export default class PostAttachmentList extends React.Component { + constructor(props) { + super(props); + } + + render() { + let content = []; + this.props.attachments.forEach((attachment, i) => { + content.push( + <PostAttachment + attachment={attachment} + key={'att_' + i} + /> + ); + }); + + return ( + <div className='attachment_list'> + {content} + </div> + ); + } +} + +PostAttachmentList.propTypes = { + attachments: React.PropTypes.array.isRequired +}; diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index e4094daf3..5a157b792 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -7,6 +7,7 @@ const Utils = require('../utils/utils.jsx'); const Constants = require('../utils/constants.jsx'); const TextFormatting = require('../utils/text_formatting.jsx'); const twemoji = require('twemoji'); +const PostBodyAdditionalContent = require('./post_body_additional_content.jsx'); export default class PostBody extends React.Component { constructor(props) { @@ -331,6 +332,9 @@ export default class PostBody extends React.Component { dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}} /> </div> + <PostBodyAdditionalContent + post={post} + /> {fileAttachmentHolder} {embed} </div> diff --git a/web/react/components/post_body_additional_content.jsx b/web/react/components/post_body_additional_content.jsx new file mode 100644 index 000000000..8189ba2d3 --- /dev/null +++ b/web/react/components/post_body_additional_content.jsx @@ -0,0 +1,56 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const PostAttachmentList = require('./post_attachment_list.jsx'); + +export default class PostBodyAdditionalContent extends React.Component { + constructor(props) { + super(props); + + this.getSlackAttachment = this.getSlackAttachment.bind(this); + this.getComponent = this.getComponent.bind(this); + } + + componentWillMount() { + this.setState({type: this.props.post.type, shouldRender: Boolean(this.props.post.type)}); + } + + getSlackAttachment() { + const attachments = this.props.post.props && this.props.post.props.attachments || []; + return ( + <PostAttachmentList + key={'post_body_additional_content' + this.props.post.id} + attachments={attachments} + /> + ); + } + + getComponent() { + switch (this.state.type) { + case 'slack_attachment': + return this.getSlackAttachment(); + } + } + + render() { + let content = []; + + if (this.state.shouldRender) { + const component = this.getComponent(); + + if (component) { + content = component; + } + } + + return ( + <div> + {content} + </div> + ); + } +} + +PostBodyAdditionalContent.propTypes = { + post: React.PropTypes.object.isRequired +};
\ No newline at end of file diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx index 03e14ec49..736919697 100644 --- a/web/react/components/search_autocomplete.jsx +++ b/web/react/components/search_autocomplete.jsx @@ -3,14 +3,15 @@ const ChannelStore = require('../stores/channel_store.jsx'); const KeyCodes = require('../utils/constants.jsx').KeyCodes; +const Popover = ReactBootstrap.Popover; const UserStore = require('../stores/user_store.jsx'); const Utils = require('../utils/utils.jsx'); +const Constants = require('../utils/constants.jsx'); const patterns = new Map([ ['channels', /\b(?:in|channel):\s*(\S*)$/i], ['users', /\bfrom:\s*(\S*)$/i] ]); -const Popover = ReactBootstrap.Popover; export default class SearchAutocomplete extends React.Component { constructor(props) { @@ -22,8 +23,13 @@ export default class SearchAutocomplete extends React.Component { this.handleKeyDown = this.handleKeyDown.bind(this); this.completeWord = this.completeWord.bind(this); + this.getSelection = this.getSelection.bind(this); + this.scrollToItem = this.scrollToItem.bind(this); this.updateSuggestions = this.updateSuggestions.bind(this); + this.renderChannelSuggestion = this.renderChannelSuggestion.bind(this); + this.renderUserSuggestion = this.renderUserSuggestion.bind(this); + this.state = { show: false, mode: '', @@ -37,9 +43,18 @@ export default class SearchAutocomplete extends React.Component { $(document).on('click', this.handleDocumentClick); } - componentDidUpdate() { - $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content').perfectScrollbar(); - $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content').css('max-height', $(window).height() - 200); + componentDidUpdate(prevProps, prevState) { + const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content'); + + if (this.state.show) { + if (!prevState.show) { + content.perfectScrollbar(); + content.css('max-height', $(window).height() - 200); + } + + // keep the keyboard selection visible when scrolling + this.scrollToItem(this.getSelection()); + } } componentWillUnmount() { @@ -51,7 +66,7 @@ export default class SearchAutocomplete extends React.Component { } handleDocumentClick(e) { - const container = $(ReactDOM.findDOMNode(this.refs.container)); + const container = $(ReactDOM.findDOMNode(this.refs.searchPopover)); if (!(container.is(e.target) || container.has(e.target).length > 0)) { this.setState({ @@ -111,15 +126,7 @@ export default class SearchAutocomplete extends React.Component { } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.SPACE) { e.preventDefault(); - this.completeSelectedWord(); - } - } - - completeSelectedWord() { - if (this.state.mode === 'channels') { - this.completeWord(this.state.suggestions[this.state.selection].name); - } else if (this.state.mode === 'users') { - this.completeWord(this.state.suggestions[this.state.selection].username); + this.completeWord(this.getSelection()); } } @@ -135,6 +142,40 @@ export default class SearchAutocomplete extends React.Component { }); } + getSelection() { + if (this.state.mode === 'channels') { + return this.state.suggestions[this.state.selection].name; + } else if (this.state.mode === 'users') { + return this.state.suggestions[this.state.selection].username; + } + + return ''; + } + + scrollToItem(itemName) { + const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content'); + const visibleContentHeight = content[0].clientHeight; + const actualContentHeight = content[0].scrollHeight; + + if (this.state.suggestions.length > 0 && visibleContentHeight < actualContentHeight) { + const contentTop = content.scrollTop(); + const contentTopPadding = parseInt(content.css('padding-top'), 10); + const contentBottomPadding = parseInt(content.css('padding-top'), 10); + + const item = $(this.refs[itemName]); + const itemTop = item[0].offsetTop - parseInt(item.css('margin-top'), 10); + const itemBottom = item[0].offsetTop + item.height() + parseInt(item.css('margin-bottom'), 10); + + if (itemTop - contentTopPadding < contentTop) { + // the item is off the top of the visible space + content.scrollTop(itemTop - contentTopPadding); + } else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) { + // the item has gone off the bottom of the visible space + content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding); + } + } + } + updateSuggestions(mode, filter) { let suggestions = []; @@ -193,6 +234,46 @@ export default class SearchAutocomplete extends React.Component { }); } + renderChannelSuggestion(channel) { + let className = 'search-autocomplete__item'; + if (channel.name === this.getSelection()) { + className += ' selected'; + } + + return ( + <div + key={channel.name} + ref={channel.name} + onClick={this.handleClick.bind(this, channel.name)} + className={className} + > + {channel.name} + </div> + ); + } + + renderUserSuggestion(user) { + let className = 'search-autocomplete__item'; + if (user.username === this.getSelection()) { + className += ' selected'; + } + + return ( + <div + key={user.username} + ref={user.username} + onClick={this.handleClick.bind(this, user.username)} + className={className} + > + <img + className='profile-img rounded' + src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at} + /> + {user.username} + </div> + ); + } + render() { if (!this.state.show || this.state.suggestions.length === 0) { return null; @@ -201,45 +282,33 @@ export default class SearchAutocomplete extends React.Component { let suggestions = []; if (this.state.mode === 'channels') { - suggestions = this.state.suggestions.map((channel, index) => { - let className = 'search-autocomplete__item'; - if (this.state.selection === index) { - className += ' selected'; - } - - return ( + const publicChannels = this.state.suggestions.filter((channel) => channel.type === Constants.OPEN_CHANNEL); + if (publicChannels.length > 0) { + suggestions.push( <div - key={channel.name} - ref={channel.name} - onClick={this.handleClick.bind(this, channel.name)} - className={className} + key='public-channel-divider' + className='search-autocomplete__divider' > - {channel.name} + {'Public ' + Utils.getChannelTerm(Constants.OPEN_CHANNEL) + 's'} </div> ); - }); - } else if (this.state.mode === 'users') { - suggestions = this.state.suggestions.map((user, index) => { - let className = 'search-autocomplete__item'; - if (this.state.selection === index) { - className += ' selected'; - } + suggestions = suggestions.concat(publicChannels.map(this.renderChannelSuggestion)); + } - return ( + const privateChannels = this.state.suggestions.filter((channel) => channel.type === Constants.PRIVATE_CHANNEL); + if (privateChannels.length > 0) { + suggestions.push( <div - key={user.username} - ref={user.username} - onClick={this.handleClick.bind(this, user.username)} - className={className} + key='private-channel-divider' + className='search-autocomplete__divider' > - <img - className='profile-img rounded' - src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at} - /> - {user.username} + {'Private ' + Utils.getChannelTerm(Constants.PRIVATE_CHANNEL) + 's'} </div> ); - }); + suggestions = suggestions.concat(privateChannels.map(this.renderChannelSuggestion)); + } + } else if (this.state.mode === 'users') { + suggestions = this.state.suggestions.map(this.renderUserSuggestion); } return ( diff --git a/web/react/components/tutorial/tutorial_intro_screens.jsx b/web/react/components/tutorial/tutorial_intro_screens.jsx index a99e9fe28..66ca556c6 100644 --- a/web/react/components/tutorial/tutorial_intro_screens.jsx +++ b/web/react/components/tutorial/tutorial_intro_screens.jsx @@ -11,12 +11,15 @@ const AsyncClient = require('../../utils/async_client.jsx'); const Constants = require('../../utils/constants.jsx'); const Preferences = Constants.Preferences; +const NUM_SCREENS = 3; + export default class TutorialIntroScreens extends React.Component { constructor(props) { super(props); this.handleNext = this.handleNext.bind(this); this.createScreen = this.createScreen.bind(this); + this.createCircles = this.createCircles.bind(this); this.state = {currentScreen: 0}; } @@ -49,31 +52,27 @@ export default class TutorialIntroScreens extends React.Component { } } createScreenOne() { + const circles = this.createCircles(); + return ( <div> <h3>{'Welcome to:'}</h3> <h1>{'Mattermost'}</h1> <p>{'Your team communication all in one place, instantly searchable and available anywhere.'}</p> <p>{'Keep your team connected to help them achieve what matters most.'}</p> - <div className='tutorial__circles'> - <div className='circle active'/> - <div className='circle'/> - <div className='circle'/> - </div> + {circles} </div> ); } createScreenTwo() { + const circles = this.createCircles(); + return ( <div> <h3>{'How Mattermost works:'}</h3> <p>{'Communication happens in public discussion channels, private groups and direct messages.'}</p> <p>{'Everything is archived and searchable from any web-enabled desktop, laptop or phone.'}</p> - <div className='tutorial__circles'> - <div className='circle'/> - <div className='circle active'/> - <div className='circle'/> - </div> + {circles} </div> ); } @@ -106,6 +105,8 @@ export default class TutorialIntroScreens extends React.Component { ); } + const circles = this.createCircles(); + return ( <div> <h3>{'You’re all set'}</h3> @@ -124,11 +125,34 @@ export default class TutorialIntroScreens extends React.Component { {'.'} </p> {'Click “Next” to enter Town Square. This is the first channel teammates see when they sign up. Use it for posting updates everyone needs to know.'} - <div className='tutorial__circles'> - <div className='circle'/> - <div className='circle'/> - <div className='circle active'/> - </div> + {circles} + </div> + ); + } + createCircles() { + const circles = []; + for (let i = 0; i < NUM_SCREENS; i++) { + let className = 'circle'; + if (i === this.state.currentScreen) { + className += ' active'; + } + + circles.push( + <a + href='#' + key={'circle' + i} + className={className} + onClick={(e) => { //eslint-disable-line no-loop-func + e.preventDefault(); + this.setState({currentScreen: i}); + }} + /> + ); + } + + return ( + <div className='tutorial__circles'> + {circles} </div> ); } diff --git a/web/react/components/user_profile.jsx b/web/react/components/user_profile.jsx index c4402ae23..eb0a8f0ca 100644 --- a/web/react/components/user_profile.jsx +++ b/web/react/components/user_profile.jsx @@ -41,7 +41,7 @@ export default class UserProfile extends React.Component { UserStore.removeChangeListener(this.onChange); } onChange(userId) { - if (userId === this.props.userId) { + if (!userId || userId === this.props.userId) { var newState = this.getStateFromStores(this.props.userId); if (!Utils.areStatesEqual(newState, this.state)) { this.setState(newState); diff --git a/web/react/components/user_settings/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx index 9f0c16194..1bfae6930 100644 --- a/web/react/components/user_settings/user_settings_general.jsx +++ b/web/react/components/user_settings/user_settings_general.jsx @@ -451,44 +451,60 @@ export default class UserSettingsGeneralTab extends React.Component { } } - inputs.push( - <div key='emailSetting'> - <div className='form-group'> - <label className='col-sm-5 control-label'>{'Primary Email'}</label> - <div className='col-sm-7'> - <input - className='form-control' - type='text' - onChange={this.updateEmail} - value={this.state.email} - /> + let submit = null; + + if (this.props.user.auth_service === '') { + inputs.push( + <div key='emailSetting'> + <div className='form-group'> + <label className='col-sm-5 control-label'>{'Primary Email'}</label> + <div className='col-sm-7'> + <input + className='form-control' + type='text' + onChange={this.updateEmail} + value={this.state.email} + /> + </div> </div> </div> - </div> - ); - - inputs.push( - <div key='confirmEmailSetting'> - <div className='form-group'> - <label className='col-sm-5 control-label'>{'Confirm Email'}</label> - <div className='col-sm-7'> - <input - className='form-control' - type='text' - onChange={this.updateConfirmEmail} - value={this.state.confirmEmail} - /> + ); + + inputs.push( + <div key='confirmEmailSetting'> + <div className='form-group'> + <label className='col-sm-5 control-label'>{'Confirm Email'}</label> + <div className='col-sm-7'> + <input + className='form-control' + type='text' + onChange={this.updateConfirmEmail} + value={this.state.confirmEmail} + /> + </div> </div> + {helpText} </div> - {helpText} - </div> - ); + ); + + submit = this.submitEmail; + } else { + inputs.push( + <div + key='oauthEmailInfo' + className='form-group' + > + <div className='setting-list__hint'>{'Log in occurs through GitLab. Email cannot be updated.'}</div> + {helpText} + </div> + ); + } emailSection = ( <SettingItemMax title='Email' inputs={inputs} - submit={this.submitEmail} + submit={submit} server_error={serverError} client_error={emailError} updateSection={function clearSection(e) { @@ -499,15 +515,19 @@ export default class UserSettingsGeneralTab extends React.Component { ); } else { let describe = ''; - if (this.state.emailChangeInProgress) { - const newEmail = UserStore.getCurrentUser().email; - if (newEmail) { - describe = 'New Address: ' + newEmail + '\nCheck your email to verify the above address.'; + if (this.props.user.auth_service === '') { + if (this.state.emailChangeInProgress) { + const newEmail = UserStore.getCurrentUser().email; + if (newEmail) { + describe = 'New Address: ' + newEmail + '\nCheck your email to verify the above address.'; + } else { + describe = 'Check your email to verify your new address'; + } } else { - describe = 'Check your email to verify your new address'; + describe = UserStore.getCurrentUser().email; } } else { - describe = UserStore.getCurrentUser().email; + describe = 'Log in done through GitLab'; } emailSection = ( diff --git a/web/react/stores/browser_store.jsx b/web/react/stores/browser_store.jsx index 75fb8aa3c..8e86ce32f 100644 --- a/web/react/stores/browser_store.jsx +++ b/web/react/stores/browser_store.jsx @@ -24,11 +24,17 @@ class BrowserStoreClass { this.setLastServerVersion = this.setLastServerVersion.bind(this); this.clear = this.clear.bind(this); this.clearAll = this.clearAll.bind(this); + this.checkedLocalStorageSupported = ''; + this.signalLogout = this.signalLogout.bind(this); var currentVersion = sessionStorage.getItem('storage_version'); if (currentVersion !== global.window.mm_config.Version) { sessionStorage.clear(); - sessionStorage.setItem('storage_version', global.window.mm_config.Version); + try { + sessionStorage.setItem('storage_version', global.window.mm_config.Version); + } catch (e) { + // Do nothing + } } } @@ -105,6 +111,13 @@ class BrowserStoreClass { sessionStorage.setItem('last_server_version', version); } + signalLogout() { + if (this.isLocalStorageSupported()) { + localStorage.setItem('__logout__', 'yes'); + localStorage.removeItem('__logout__'); + } + } + /** * Preforms the given action on each item that has the given prefix * Signature for action is action(key, value) @@ -147,20 +160,26 @@ class BrowserStoreClass { } isLocalStorageSupported() { + if (this.checkedLocalStorageSupported !== '') { + return this.checkedLocalStorageSupported; + } + try { - sessionStorage.setItem('testSession', '1'); - sessionStorage.removeItem('testSession'); + sessionStorage.setItem('__testSession__', '1'); + sessionStorage.removeItem('__testSession__'); - localStorage.setItem('testLocal', '1'); - if (localStorage.getItem('testLocal') !== '1') { - return false; + localStorage.setItem('__testLocal__', '1'); + if (localStorage.getItem('__testLocal__') !== '1') { + this.checkedLocalStorageSupported = false; } - localStorage.removeItem('testLocal', '1'); + localStorage.removeItem('__testLocal__', '1'); - return true; + this.checkedLocalStorageSupported = true; } catch (e) { - return false; + this.checkedLocalStorageSupported = false; } + + return this.checkedLocalStorageSupported; } } diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx index 4fa7224b7..6b7d671fc 100644 --- a/web/react/stores/user_store.jsx +++ b/web/react/stores/user_store.jsx @@ -58,6 +58,8 @@ class UserStoreClass extends EventEmitter { this.setStatus = this.setStatus.bind(this); this.getStatuses = this.getStatuses.bind(this); this.getStatus = this.getStatus.bind(this); + + this.profileCache = null; } emitChange(userId) { @@ -184,6 +186,10 @@ class UserStoreClass extends EventEmitter { } getProfiles() { + if (this.profileCache !== null) { + return this.profileCache; + } + return BrowserStore.getItem('profiles', {}); } @@ -218,6 +224,7 @@ class UserStoreClass extends EventEmitter { saveProfile(profile) { var ps = this.getProfiles(); ps[profile.id] = profile; + this.profileCache = ps; BrowserStore.setItem('profiles', ps); } @@ -226,6 +233,8 @@ class UserStoreClass extends EventEmitter { if (currentId in profiles) { delete profiles[currentId]; } + + this.profileCache = profiles; BrowserStore.setItem('profiles', profiles); } diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index 003e24d33..d27fe16cf 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -231,6 +231,7 @@ export function resetPassword(data, success, error) { export function logout() { track('api', 'api_users_logout'); var currentTeamUrl = TeamStore.getCurrentTeamUrl(); + BrowserStore.signalLogout(); BrowserStore.clear(); ErrorStore.storeLastError(null); window.location.href = currentTeamUrl + '/logout'; diff --git a/web/react/utils/highlight.jsx b/web/react/utils/highlight.jsx new file mode 100644 index 000000000..68fef7930 --- /dev/null +++ b/web/react/utils/highlight.jsx @@ -0,0 +1,51 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const highlightJs = require('highlight.js/lib/highlight.js'); +const highlightJsDiff = require('highlight.js/lib/languages/diff.js'); +const highlightJsApache = require('highlight.js/lib/languages/apache.js'); +const highlightJsMakefile = require('highlight.js/lib/languages/makefile.js'); +const highlightJsHttp = require('highlight.js/lib/languages/http.js'); +const highlightJsJson = require('highlight.js/lib/languages/json.js'); +const highlightJsMarkdown = require('highlight.js/lib/languages/markdown.js'); +const highlightJsJavascript = require('highlight.js/lib/languages/javascript.js'); +const highlightJsCss = require('highlight.js/lib/languages/css.js'); +const highlightJsNginx = require('highlight.js/lib/languages/nginx.js'); +const highlightJsObjectivec = require('highlight.js/lib/languages/objectivec.js'); +const highlightJsPython = require('highlight.js/lib/languages/python.js'); +const highlightJsXml = require('highlight.js/lib/languages/xml.js'); +const highlightJsPerl = require('highlight.js/lib/languages/perl.js'); +const highlightJsBash = require('highlight.js/lib/languages/bash.js'); +const highlightJsPhp = require('highlight.js/lib/languages/php.js'); +const highlightJsCoffeescript = require('highlight.js/lib/languages/coffeescript.js'); +const highlightJsCs = require('highlight.js/lib/languages/cs.js'); +const highlightJsCpp = require('highlight.js/lib/languages/cpp.js'); +const highlightJsSql = require('highlight.js/lib/languages/sql.js'); +const highlightJsGo = require('highlight.js/lib/languages/go.js'); +const highlightJsRuby = require('highlight.js/lib/languages/ruby.js'); +const highlightJsJava = require('highlight.js/lib/languages/java.js'); +const highlightJsIni = require('highlight.js/lib/languages/ini.js'); + +highlightJs.registerLanguage('diff', highlightJsDiff); +highlightJs.registerLanguage('apache', highlightJsApache); +highlightJs.registerLanguage('makefile', highlightJsMakefile); +highlightJs.registerLanguage('http', highlightJsHttp); +highlightJs.registerLanguage('json', highlightJsJson); +highlightJs.registerLanguage('markdown', highlightJsMarkdown); +highlightJs.registerLanguage('javascript', highlightJsJavascript); +highlightJs.registerLanguage('css', highlightJsCss); +highlightJs.registerLanguage('nginx', highlightJsNginx); +highlightJs.registerLanguage('objectivec', highlightJsObjectivec); +highlightJs.registerLanguage('python', highlightJsPython); +highlightJs.registerLanguage('xml', highlightJsXml); +highlightJs.registerLanguage('perl', highlightJsPerl); +highlightJs.registerLanguage('bash', highlightJsBash); +highlightJs.registerLanguage('php', highlightJsPhp); +highlightJs.registerLanguage('coffeescript', highlightJsCoffeescript); +highlightJs.registerLanguage('cs', highlightJsCs); +highlightJs.registerLanguage('cpp', highlightJsCpp); +highlightJs.registerLanguage('sql', highlightJsSql); +highlightJs.registerLanguage('go', highlightJsGo); +highlightJs.registerLanguage('ruby', highlightJsRuby); +highlightJs.registerLanguage('java', highlightJsJava); +highlightJs.registerLanguage('ini', highlightJsIni); diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index 3ef09211f..374caf6dc 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -1,38 +1,14 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +require('./highlight.jsx'); const TextFormatting = require('./text_formatting.jsx'); const Utils = require('./utils.jsx'); +const highlightJs = require('highlight.js/lib/highlight.js'); const marked = require('marked'); -const highlightJs = require('highlight.js/lib/highlight.js'); -const highlightJsDiff = require('highlight.js/lib/languages/diff.js'); -const highlightJsApache = require('highlight.js/lib/languages/apache.js'); -const highlightJsMakefile = require('highlight.js/lib/languages/makefile.js'); -const highlightJsHttp = require('highlight.js/lib/languages/http.js'); -const highlightJsJson = require('highlight.js/lib/languages/json.js'); -const highlightJsMarkdown = require('highlight.js/lib/languages/markdown.js'); -const highlightJsJavascript = require('highlight.js/lib/languages/javascript.js'); -const highlightJsCss = require('highlight.js/lib/languages/css.js'); -const highlightJsNginx = require('highlight.js/lib/languages/nginx.js'); -const highlightJsObjectivec = require('highlight.js/lib/languages/objectivec.js'); -const highlightJsPython = require('highlight.js/lib/languages/python.js'); -const highlightJsXml = require('highlight.js/lib/languages/xml.js'); -const highlightJsPerl = require('highlight.js/lib/languages/perl.js'); -const highlightJsBash = require('highlight.js/lib/languages/bash.js'); -const highlightJsPhp = require('highlight.js/lib/languages/php.js'); -const highlightJsCoffeescript = require('highlight.js/lib/languages/coffeescript.js'); -const highlightJsCs = require('highlight.js/lib/languages/cs.js'); -const highlightJsCpp = require('highlight.js/lib/languages/cpp.js'); -const highlightJsSql = require('highlight.js/lib/languages/sql.js'); -const highlightJsGo = require('highlight.js/lib/languages/go.js'); -const highlightJsRuby = require('highlight.js/lib/languages/ruby.js'); -const highlightJsJava = require('highlight.js/lib/languages/java.js'); -const highlightJsIni = require('highlight.js/lib/languages/ini.js'); - -const Constants = require('../utils/constants.jsx'); -const HighlightedLanguages = Constants.HighlightedLanguages; +const HighlightedLanguages = require('../utils/constants.jsx').HighlightedLanguages; function markdownImageLoaded(image) { image.style.height = 'auto'; @@ -84,30 +60,6 @@ class MattermostMarkdownRenderer extends marked.Renderer { this.text = this.text.bind(this); this.formattingOptions = formattingOptions; - - highlightJs.registerLanguage('diff', highlightJsDiff); - highlightJs.registerLanguage('apache', highlightJsApache); - highlightJs.registerLanguage('makefile', highlightJsMakefile); - highlightJs.registerLanguage('http', highlightJsHttp); - highlightJs.registerLanguage('json', highlightJsJson); - highlightJs.registerLanguage('markdown', highlightJsMarkdown); - highlightJs.registerLanguage('javascript', highlightJsJavascript); - highlightJs.registerLanguage('css', highlightJsCss); - highlightJs.registerLanguage('nginx', highlightJsNginx); - highlightJs.registerLanguage('objectivec', highlightJsObjectivec); - highlightJs.registerLanguage('python', highlightJsPython); - highlightJs.registerLanguage('xml', highlightJsXml); - highlightJs.registerLanguage('perl', highlightJsPerl); - highlightJs.registerLanguage('bash', highlightJsBash); - highlightJs.registerLanguage('php', highlightJsPhp); - highlightJs.registerLanguage('coffeescript', highlightJsCoffeescript); - highlightJs.registerLanguage('cs', highlightJsCs); - highlightJs.registerLanguage('cpp', highlightJsCpp); - highlightJs.registerLanguage('sql', highlightJsSql); - highlightJs.registerLanguage('go', highlightJsGo); - highlightJs.registerLanguage('ruby', highlightJsRuby); - highlightJs.registerLanguage('java', highlightJsJava); - highlightJs.registerLanguage('ini', highlightJsIni); } code(code, language) { @@ -204,6 +156,301 @@ class MattermostMarkdownRenderer extends marked.Renderer { } } +class MattermostLexer extends marked.Lexer { + token(originalSrc, top, bq) { + let src = originalSrc.replace(/^ +$/gm, ''); + + while (src) { + // newline + let cap = this.rules.newline.exec(src); + if (cap) { + src = src.substring(cap[0].length); + if (cap[0].length > 1) { + this.tokens.push({ + type: 'space' + }); + } + } + + // code + cap = this.rules.code.exec(src); + if (cap) { + src = src.substring(cap[0].length); + cap = cap[0].replace(/^ {4}/gm, ''); + this.tokens.push({ + type: 'code', + text: this.options.pedantic ? cap : cap.replace(/\n+$/, '') + }); + continue; + } + + // fences (gfm) + cap = this.rules.fences.exec(src); + if (cap) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'code', + lang: cap[2], + text: cap[3] || '' + }); + continue; + } + + // heading + cap = this.rules.heading.exec(src); + if (cap) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'heading', + depth: cap[1].length, + text: cap[2] + }); + continue; + } + + // table no leading pipe (gfm) + cap = this.rules.nptable.exec(src); + if (top && cap) { + src = src.substring(cap[0].length); + + const item = { + type: 'table', + header: cap[1].replace(/^ *| *\| *$/g, '').split(/ *\| */), + align: cap[2].replace(/^ *|\| *$/g, '').split(/ *\| */), + cells: cap[3].replace(/\n$/, '').split('\n') + }; + + for (let i = 0; i < item.align.length; i++) { + if (/^ *-+: *$/.test(item.align[i])) { + item.align[i] = 'right'; + } else if (/^ *:-+: *$/.test(item.align[i])) { + item.align[i] = 'center'; + } else if (/^ *:-+ *$/.test(item.align[i])) { + item.align[i] = 'left'; + } else { + item.align[i] = null; + } + } + + for (let i = 0; i < item.cells.length; i++) { + item.cells[i] = item.cells[i].split(/ *\| */); + } + + this.tokens.push(item); + + continue; + } + + // lheading + cap = this.rules.lheading.exec(src); + if (cap) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'heading', + depth: cap[2] === '=' ? 1 : 2, + text: cap[1] + }); + continue; + } + + // hr + cap = this.rules.hr.exec(src); + if (cap) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'hr' + }); + continue; + } + + // blockquote + cap = this.rules.blockquote.exec(src); + if (cap) { + src = src.substring(cap[0].length); + + this.tokens.push({ + type: 'blockquote_start' + }); + + cap = cap[0].replace(/^ *> ?/gm, ''); + + // Pass `top` to keep the current + // "toplevel" state. This is exactly + // how markdown.pl works. + this.token(cap, top, true); + + this.tokens.push({ + type: 'blockquote_end' + }); + + continue; + } + + // list + cap = this.rules.list.exec(src); + if (cap) { + const bull = cap[2]; + let l = cap[0].length; + + // Get each top-level item. + cap = cap[0].match(this.rules.item); + + if (cap.length > 1) { + src = src.substring(l); + + this.tokens.push({ + type: 'list_start', + ordered: bull.length > 1 + }); + + let next = false; + l = cap.length; + + for (let i = 0; i < l; i++) { + let item = cap[i]; + + // Remove the list item's bullet + // so it is seen as the next token. + let space = item.length; + item = item.replace(/^ *([*+-]|\d+\.) +/, ''); + + // Outdent whatever the + // list item contains. Hacky. + if (~item.indexOf('\n ')) { + space -= item.length; + item = this.options.pedantic ? item.replace(/^ {1,4}/gm, '') : item.replace(new RegExp('^ \{1,' + space + '\}', 'gm'), ''); + } + + // Determine whether the next list item belongs here. + // Backpedal if it does not belong in this list. + if (this.options.smartLists && i !== l - 1) { + const bullet = /(?:[*+-]|\d+\.)/; + const b = bullet.exec(cap[i + 1])[0]; + if (bull !== b && !(bull.length > 1 && b.length > 1)) { + src = cap.slice(i + 1).join('\n') + src; + i = l - 1; + } + } + + // Determine whether item is loose or not. + // Use: /(^|\n)(?! )[^\n]+\n\n(?!\s*$)/ + // for discount behavior. + let loose = next || (/\n\n(?!\s*$)/).test(item); + if (i !== l - 1) { + next = item.charAt(item.length - 1) === '\n'; + if (!loose) { + loose = next; + } + } + + this.tokens.push({ + type: loose ? 'loose_item_start' : 'list_item_start' + }); + + // Recurse. + this.token(item, false, bq); + + this.tokens.push({ + type: 'list_item_end' + }); + } + + this.tokens.push({ + type: 'list_end' + }); + + continue; + } + } + + // html + cap = this.rules.html.exec(src); + if (cap) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: this.options.sanitize ? 'paragraph' : 'html', + pre: !this.options.sanitizer && (cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style'), + text: cap[0] + }); + continue; + } + + // def + cap = this.rules.def.exec(src); + if ((!bq && top) && cap) { + src = src.substring(cap[0].length); + this.tokens.links[cap[1].toLowerCase()] = { + href: cap[2], + title: cap[3] + }; + continue; + } + + // table (gfm) + cap = this.rules.table.exec(src); + if (top && cap) { + src = src.substring(cap[0].length); + + const item = { + type: 'table', + header: cap[1].replace(/^ *| *\| *$/g, '').split(/ *\| */), + align: cap[2].replace(/^ *|\| *$/g, '').split(/ *\| */), + cells: cap[3].replace(/(?: *\| *)?\n$/, '').split('\n') + }; + + for (let i = 0; i < item.align.length; i++) { + if (/^ *-+: *$/.test(item.align[i])) { + item.align[i] = 'right'; + } else if (/^ *:-+: *$/.test(item.align[i])) { + item.align[i] = 'center'; + } else if (/^ *:-+ *$/.test(item.align[i])) { + item.align[i] = 'left'; + } else { + item.align[i] = null; + } + } + + for (let i = 0; i < item.cells.length; i++) { + item.cells[i] = item.cells[i].replace(/^ *\| *| *\| *$/g, '').split(/ *\| */); + } + + this.tokens.push(item); + + continue; + } + + // top-level paragraph + cap = this.rules.paragraph.exec(src); + if (top && cap) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'paragraph', + text: cap[1].charAt(cap[1].length - 1) === '\n' ? cap[1].slice(0, -1) : cap[1] + }); + continue; + } + + // text + cap = this.rules.text.exec(src); + if (cap) { + // Top-level should never reach here. + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'text', + text: cap[0] + }); + continue; + } + + if (src) { + throw new Error('Infinite loop on byte: ' + src.charCodeAt(0)); + } + } + + return this.tokens; + } +} + export function format(text, options) { const markdownOptions = { renderer: new MattermostMarkdownRenderer(null, options), @@ -212,7 +459,7 @@ export function format(text, options) { tables: true }; - const tokens = marked.lexer(text, markdownOptions); + const tokens = new MattermostLexer(markdownOptions).lex(text); return new MattermostParser(markdownOptions).parse(tokens); } diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index fff9c460b..8052c000c 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -155,7 +155,7 @@ var canDing = true; export function ding() { if (!isBrowserFirefox() && canDing) { - var audio = new Audio('/static/images/ding.mp3'); + var audio = new Audio('/static/images/bing.mp3'); audio.play(); canDing = false; setTimeout(() => canDing = true, 3000); @@ -481,6 +481,7 @@ export function applyTheme(theme) { changeCss('.modal .modal-header', 'background:' + theme.sidebarHeaderBg, 1); changeCss('#navbar .navbar-default', 'background:' + theme.sidebarHeaderBg, 1); changeCss('@media(max-width: 768px){.search-bar__container', 'background:' + theme.sidebarHeaderBg, 1); + changeCss('.attachment .attachment__container', 'border-left-color:' + theme.sidebarHeaderBg, 1); } if (theme.sidebarHeaderTextColor) { @@ -519,6 +520,7 @@ export function applyTheme(theme) { changeCss('.popover.left>.arrow:after', 'border-left-color:' + theme.centerChannelBg, 1); changeCss('.popover.top>.arrow:after, .tip-overlay.tip-overlay--chat .arrow', 'border-top-color:' + theme.centerChannelBg, 1); changeCss('.search-bar__container .search__form .search-bar, .form-control', 'background:' + theme.centerChannelBg, 1); + changeCss('.attachment__content', 'background:' + theme.centerChannelBg, 1); } if (theme.centerChannelColor) { @@ -552,6 +554,7 @@ export function applyTheme(theme) { changeCss('@media(max-width: 768px){.search-bar__container .search__form .search-bar', 'background:' + changeOpacity(theme.centerChannelColor, 0.2) + '; color: inherit;', 1); changeCss('.input-group-addon, .search-bar__container .search__form, .form-control', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); changeCss('.form-control:focus', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1); + changeCss('.attachment .attachment__content', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.3), 1); changeCss('.channel-intro .channel-intro__content, .webhooks__container', 'background:' + changeOpacity(theme.centerChannelColor, 0.05), 1); changeCss('.date-separator .separator__text', 'color:' + theme.centerChannelColor, 2); changeCss('.date-separator .separator__hr, .modal-footer, .modal .custom-textarea, .post-right__container .post.post--root hr, .search-item-container', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2), 1); @@ -1129,3 +1132,12 @@ export function sortByDisplayName(a, b) { } return 0; } + +export function getChannelTerm(channelType) { + let channelTerm = 'Channel'; + if (channelType === Constants.PRIVATE_CHANNEL) { + channelTerm = 'Group'; + } + + return channelTerm; +} |