diff options
Diffstat (limited to 'web/react')
41 files changed, 1013 insertions, 412 deletions
diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx index c8af2553d..f0a31ce90 100644 --- a/web/react/components/access_history_modal.jsx +++ b/web/react/components/access_history_modal.jsx @@ -90,8 +90,9 @@ export default class AccessHistoryModal extends React.Component { case '/channels/update': currentAuditDesc = 'Updated the ' + channelName + ' channel/group name'; break; - case '/channels/update_desc': - currentAuditDesc = 'Updated the ' + channelName + ' channel/group description'; + case '/channels/update_desc': // support the old path + case '/channels/update_header': + currentAuditDesc = 'Updated the ' + channelName + ' channel/group header'; break; default: let userIdField = []; diff --git a/web/react/components/admin_console/team_analytics.jsx b/web/react/components/admin_console/team_analytics.jsx index a945a551c..0c9d1f61b 100644 --- a/web/react/components/admin_console/team_analytics.jsx +++ b/web/react/components/admin_console/team_analytics.jsx @@ -210,69 +210,89 @@ export default class TeamAnalytics extends React.Component { } var totalCount = ( - <div className='total-count text-center'> - <div>{'Total Users'}</div> - <div>{this.state.users == null ? 'Loading...' : Object.keys(this.state.users).length}</div> + <div className='col-sm-3'> + <div className='total-count'> + <div className='title'>{'Total Users'}<i className='fa fa-users'/></div> + <div className='content'>{this.state.users == null ? 'Loading...' : Object.keys(this.state.users).length}</div> + </div> </div> ); var openChannelCount = ( - <div className='total-count text-center'> - <div>{'Public Groups'}</div> - <div>{this.state.channel_open_count == null ? 'Loading...' : this.state.channel_open_count}</div> + <div className='col-sm-3'> + <div className='total-count'> + <div className='title'>{'Public Groups'}<i className='fa fa-unlock-alt'/></div> + <div className='content'>{this.state.channel_open_count == null ? 'Loading...' : this.state.channel_open_count}</div> + </div> </div> ); var openPrivateCount = ( - <div className='total-count text-center'> - <div>{'Private Groups'}</div> - <div>{this.state.channel_private_count == null ? 'Loading...' : this.state.channel_private_count}</div> + <div className='col-sm-3'> + <div className='total-count'> + <div className='title'>{'Private Groups'}<i className='fa fa-lock'/></div> + <div className='content'>{this.state.channel_private_count == null ? 'Loading...' : this.state.channel_private_count}</div> + </div> </div> ); var postCount = ( - <div className='total-count text-center'> - <div>{'Total Posts'}</div> - <div>{this.state.post_count == null ? 'Loading...' : this.state.post_count}</div> + <div className='col-sm-3'> + <div className='total-count'> + <div className='title'>{'Total Posts'}<i className='fa fa-comment'/></div> + <div className='content'>{this.state.post_count == null ? 'Loading...' : this.state.post_count}</div> + </div> </div> ); var postCountsByDay = ( - <div className='total-count-by-day'> - <div>{'Total Posts'}</div> - <div>{'Loading...'}</div> + <div className='col-sm-12'> + <div className='total-count by-day'> + <div className='title'>{'Total Posts'}</div> + <div className='content'>{'Loading...'}</div> + </div> </div> ); if (this.state.post_counts_day != null) { postCountsByDay = ( - <div className='total-count-by-day'> - <div>{'Total Posts'}</div> - <LineChart - data={this.state.post_counts_day} - width='740' - height='225' - /> + <div className='col-sm-12'> + <div className='total-count by-day'> + <div className='title'>{'Total Posts'}</div> + <div className='content'> + <LineChart + data={this.state.post_counts_day} + width='740' + height='225' + /> + </div> + </div> </div> ); } var usersWithPostsByDay = ( - <div className='total-count-by-day'> - <div>{'Total Posts'}</div> - <div>{'Loading...'}</div> + <div className='col-sm-12'> + <div className='total-count by-day'> + <div className='title'>{'Total Posts'}</div> + <div>{'Loading...'}</div> + </div> </div> ); if (this.state.user_counts_with_posts_day != null) { usersWithPostsByDay = ( - <div className='total-count-by-day'> - <div>{'Active Users With Posts'}</div> - <LineChart - data={this.state.user_counts_with_posts_day} - width='740' - height='225' - /> + <div className='col-sm-12'> + <div className='total-count by-day'> + <div className='title'>{'Active Users With Posts'}</div> + <div className='content'> + <LineChart + data={this.state.user_counts_with_posts_day} + width='740' + height='225' + /> + </div> + </div> </div> ); } @@ -286,22 +306,26 @@ export default class TeamAnalytics extends React.Component { if (this.state.recent_active_users != null) { recentActiveUser = ( - <div className='recent-active-users'> - <div>{'Recent Active Users'}</div> - <table width='90%'> - <tbody> - { - this.state.recent_active_users.map((user) => { - return ( - <tr key={user.id}> - <td className='recent-active-users-td'>{user.email}</td> - <td className='recent-active-users-td'>{Utils.displayDateTime(user.last_activity_at)}</td> - </tr> - ); - }) - } - </tbody> - </table> + <div className='col-sm-6'> + <div className='total-count recent-active-users'> + <div className='title'>{'Recent Active Users'}</div> + <div className='content'> + <table> + <tbody> + { + this.state.recent_active_users.map((user) => { + return ( + <tr key={user.id}> + <td>{user.email}</td> + <td>{Utils.displayDateTime(user.last_activity_at)}</td> + </tr> + ); + }) + } + </tbody> + </table> + </div> + </div> </div> ); } @@ -315,38 +339,50 @@ export default class TeamAnalytics extends React.Component { if (this.state.newly_created_users != null) { newUsers = ( - <div className='recent-active-users'> - <div>{'Newly Created Users'}</div> - <table width='90%'> - <tbody> - { - this.state.newly_created_users.map((user) => { - return ( - <tr key={user.id}> - <td className='recent-active-users-td'>{user.email}</td> - <td className='recent-active-users-td'>{Utils.displayDateTime(user.create_at)}</td> - </tr> - ); - }) - } - </tbody> - </table> + <div className='col-sm-6'> + <div className='total-count recent-active-users'> + <div className='title'>{'Newly Created Users'}</div> + <div className='content'> + <table> + <tbody> + { + this.state.newly_created_users.map((user) => { + return ( + <tr key={user.id}> + <td>{user.email}</td> + <td>{Utils.displayDateTime(user.create_at)}</td> + </tr> + ); + }) + } + </tbody> + </table> + </div> + </div> </div> ); } return ( - <div className='wrapper--fixed'> - <h2>{'Statistics for ' + this.props.team.name}</h2> + <div className='wrapper--fixed team_statistics'> + <h3>{'Statistics for ' + this.props.team.name}</h3> {serverError} - {totalCount} - {postCount} - {openChannelCount} - {openPrivateCount} - {postCountsByDay} - {usersWithPostsByDay} - {recentActiveUser} - {newUsers} + <div className='row'> + {totalCount} + {postCount} + {openChannelCount} + {openPrivateCount} + </div> + <div className='row'> + {postCountsByDay} + </div> + <div className='row'> + {usersWithPostsByDay} + </div> + <div className='row'> + {recentActiveUser} + {newUsers} + </div> </div> ); } diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx index d66777cc6..101fd85e5 100644 --- a/web/react/components/channel_header.jsx +++ b/web/react/components/channel_header.jsx @@ -11,6 +11,7 @@ const TextFormatting = require('../utils/text_formatting.jsx'); const Utils = require('../utils/utils.jsx'); const MessageWrapper = require('./message_wrapper.jsx'); const PopoverListMembers = require('./popover_list_members.jsx'); +const EditChannelPurposeModal = require('./edit_channel_purpose_modal.jsx'); const AppDispatcher = require('../dispatcher/app_dispatcher.jsx'); const Constants = require('../utils/constants.jsx'); @@ -27,7 +28,9 @@ export default class ChannelHeader extends React.Component { this.handleLeave = this.handleLeave.bind(this); this.searchMentions = this.searchMentions.bind(this); - this.state = this.getStateFromStores(); + const state = this.getStateFromStores(); + state.showEditChannelPurposeModal = false; + this.state = state; } getStateFromStores() { return { @@ -110,11 +113,11 @@ export default class ChannelHeader extends React.Component { bSize='large' placement='bottom' className='description' - onMouseOver={() => this.refs.descriptionOverlay.show()} - onMouseOut={() => this.refs.descriptionOverlay.hide()} + onMouseOver={() => this.refs.headerOverlay.show()} + onMouseOut={() => this.refs.headerOverlay.hide()} > <MessageWrapper - message={channel.description} + message={channel.header} /> </Popover> ); @@ -144,7 +147,7 @@ export default class ChannelHeader extends React.Component { if (isDirect) { dropdownContents.push( <li - key='edit_description_direct' + key='edit_header_direct' role='presentation' > <a @@ -152,11 +155,11 @@ export default class ChannelHeader extends React.Component { href='#' data-toggle='modal' data-target='#edit_channel' - data-desc={channel.description} + data-header={channel.header} data-title={channel.display_name} data-channelid={channel.id} > - Set Channel Description... + Set Channel Header... </a> </li> ); @@ -216,7 +219,7 @@ export default class ChannelHeader extends React.Component { dropdownContents.push( <li - key='set_channel_description' + key='set_channel_header' role='presentation' > <a @@ -224,11 +227,25 @@ export default class ChannelHeader extends React.Component { href='#' data-toggle='modal' data-target='#edit_channel' - data-desc={channel.description} + data-header={channel.header} data-title={channel.display_name} data-channelid={channel.id} > - Set {channelTerm} Description... + Set {channelTerm} Header... + </a> + </li> + ); + dropdownContents.push( + <li + key='set_channel_purpose' + role='presentation' + > + <a + role='menuitem' + href='#' + onClick={() => this.setState({showEditChannelPurposeModal: true})} + > + Set {channelTerm} Purpose... </a> </li> ); @@ -307,84 +324,91 @@ export default class ChannelHeader extends React.Component { } return ( - <table className='channel-header alt'> - <tbody> - <tr> - <th> - <div className='channel-header__info'> - <div className='dropdown'> + <div> + <table className='channel-header alt'> + <tbody> + <tr> + <th> + <div className='channel-header__info'> + <div className='dropdown'> + <a + href='#' + className='dropdown-toggle theme' + type='button' + id='channel_header_dropdown' + data-toggle='dropdown' + aria-expanded='true' + > + <strong className='heading'>{channelTitle} </strong> + <span className='glyphicon glyphicon-chevron-down header-dropdown__icon' /> + </a> + <ul + className='dropdown-menu' + role='menu' + aria-labelledby='channel_header_dropdown' + > + {dropdownContents} + </ul> + </div> + <OverlayTrigger + trigger={['hover', 'focus']} + placement='bottom' + overlay={popoverContent} + ref='headerOverlay' + > + <div + onClick={TextFormatting.handleClick} + className='description' + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(channel.header, {singleline: true, mentionHighlight: false})}} + /> + </OverlayTrigger> + </div> + </th> + <th> + <PopoverListMembers + members={this.state.users} + channelId={channel.id} + /> + </th> + <th className='search-bar__container'><NavbarSearchBox /></th> + <th> + <div className='dropdown channel-header__links'> <a href='#' className='dropdown-toggle theme' type='button' - id='channel_header_dropdown' + id='channel_header_right_dropdown' data-toggle='dropdown' aria-expanded='true' > - <strong className='heading'>{channelTitle} </strong> - <span className='glyphicon glyphicon-chevron-down header-dropdown__icon' /> + <span dangerouslySetInnerHTML={{__html: Constants.MENU_ICON}} /> </a> <ul - className='dropdown-menu' + className='dropdown-menu dropdown-menu-right' role='menu' - aria-labelledby='channel_header_dropdown' + aria-labelledby='channel_header_right_dropdown' > - {dropdownContents} + <li role='presentation'> + <a + role='menuitem' + href='#' + onClick={this.searchMentions} + > + Recent Mentions + </a> + </li> </ul> </div> - <OverlayTrigger - trigger={['hover', 'focus']} - placement='bottom' - overlay={popoverContent} - ref='descriptionOverlay' - > - <div - onClick={TextFormatting.handleClick} - className='description' - dangerouslySetInnerHTML={{__html: TextFormatting.formatText(channel.description, {singleline: true, mentionHighlight: false})}} - /> - </OverlayTrigger> - </div> - </th> - <th> - <PopoverListMembers - members={this.state.users} - channelId={channel.id} - /> - </th> - <th className='search-bar__container'><NavbarSearchBox /></th> - <th> - <div className='dropdown channel-header__links'> - <a - href='#' - className='dropdown-toggle theme' - type='button' - id='channel_header_right_dropdown' - data-toggle='dropdown' - aria-expanded='true' - > - <span dangerouslySetInnerHTML={{__html: Constants.MENU_ICON}} /> - </a> - <ul - className='dropdown-menu dropdown-menu-right' - role='menu' - aria-labelledby='channel_header_right_dropdown' - > - <li role='presentation'> - <a - role='menuitem' - href='#' - onClick={this.searchMentions} - > - Recent Mentions - </a> - </li> - </ul> - </div> - </th> - </tr> - </tbody> - </table> + </th> + </tr> + </tbody> + </table> + <EditChannelPurposeModal + show={this.state.showEditChannelPurposeModal} + onModalDismissed={() => this.setState({showEditChannelPurposeModal: false})} + channel={channel} + /> + </div> ); } } diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx index 18936e808..058594165 100644 --- a/web/react/components/create_comment.jsx +++ b/web/react/components/create_comment.jsx @@ -8,6 +8,7 @@ const SocketStore = require('../stores/socket_store.jsx'); const ChannelStore = require('../stores/channel_store.jsx'); const UserStore = require('../stores/user_store.jsx'); const PostStore = require('../stores/post_store.jsx'); +const PreferenceStore = require('../stores/preference_store.jsx'); const Textbox = require('./textbox.jsx'); const MsgTyping = require('./msg_typing.jsx'); const FileUpload = require('./file_upload.jsx'); @@ -27,7 +28,7 @@ export default class CreateComment extends React.Component { this.handleSubmit = this.handleSubmit.bind(this); this.commentMsgKeyPress = this.commentMsgKeyPress.bind(this); this.handleUserInput = this.handleUserInput.bind(this); - this.handleArrowUp = this.handleArrowUp.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); this.handleUploadStart = this.handleUploadStart.bind(this); this.handleFileUploadComplete = this.handleFileUploadComplete.bind(this); this.handleUploadError = this.handleUploadError.bind(this); @@ -36,6 +37,7 @@ export default class CreateComment extends React.Component { this.handleSubmit = this.handleSubmit.bind(this); this.getFileCount = this.getFileCount.bind(this); this.handleResize = this.handleResize.bind(this); + this.onPreferenceChange = this.onPreferenceChange.bind(this); PostStore.clearCommentDraftUploads(); @@ -45,15 +47,23 @@ export default class CreateComment extends React.Component { uploadsInProgress: draft.uploadsInProgress, previews: draft.previews, submitting: false, - windowWidth: Utils.windowWidth() + windowWidth: Utils.windowWidth(), + ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value }; } componentDidMount() { + PreferenceStore.addChangeListener(this.onPreferenceChange); window.addEventListener('resize', this.handleResize); } componentWillUnmount() { + PreferenceStore.removeChangeListener(this.onPreferenceChange); window.removeEventListener('resize', this.handleResize); } + onPreferenceChange() { + this.setState({ + ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value + }); + } handleResize() { this.setState({windowWidth: Utils.windowWidth()}); } @@ -140,10 +150,12 @@ export default class CreateComment extends React.Component { this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null}); } commentMsgKeyPress(e) { - if (e.which === 13 && !e.shiftKey && !e.altKey) { - e.preventDefault(); - ReactDOM.findDOMNode(this.refs.textbox).blur(); - this.handleSubmit(e); + if (this.state.ctrlSend === 'true' && e.ctrlKey || this.state.ctrlSend === 'false') { + if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) { + e.preventDefault(); + ReactDOM.findDOMNode(this.refs.textbox).blur(); + this.handleSubmit(e); + } } const t = Date.now(); @@ -161,7 +173,12 @@ export default class CreateComment extends React.Component { $('.post-right__scroll').perfectScrollbar('update'); this.setState({messageText: messageText}); } - handleArrowUp(e) { + handleKeyDown(e) { + if (this.state.ctrlSend === 'true' && e.keyCode === KeyCodes.ENTER && e.ctrlKey === true) { + this.commentMsgKeyPress(e); + return; + } + if (e.keyCode === KeyCodes.UP && this.state.messageText === '') { e.preventDefault(); @@ -313,7 +330,7 @@ export default class CreateComment extends React.Component { <Textbox onUserInput={this.handleUserInput} onKeyPress={this.commentMsgKeyPress} - onKeyDown={this.handleArrowUp} + onKeyDown={this.handleKeyDown} messageText={this.state.messageText} createMessage='Add a comment...' initialText='' diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index 32ee31efe..cdbc3bc6d 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -8,6 +8,7 @@ const ChannelStore = require('../stores/channel_store.jsx'); const PostStore = require('../stores/post_store.jsx'); const UserStore = require('../stores/user_store.jsx'); const SocketStore = require('../stores/socket_store.jsx'); +const PreferenceStore = require('../stores/preference_store.jsx'); const MsgTyping = require('./msg_typing.jsx'); const Textbox = require('./textbox.jsx'); const FileUpload = require('./file_upload.jsx'); @@ -36,9 +37,10 @@ export default class CreatePost extends React.Component { this.removePreview = this.removePreview.bind(this); this.onChange = this.onChange.bind(this); this.getFileCount = this.getFileCount.bind(this); - this.handleArrowUp = this.handleArrowUp.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); this.handleResize = this.handleResize.bind(this); this.sendMessage = this.sendMessage.bind(this); + this.onPreferenceChange = this.onPreferenceChange.bind(this); PostStore.clearDraftUploads(); @@ -52,8 +54,16 @@ export default class CreatePost extends React.Component { submitting: false, initialText: draft.messageText, windowWidth: Utils.windowWidth(), - windowHeight: Utils.windowHeight() + windowHeight: Utils.windowHeight(), + ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value }; + + PreferenceStore.addChangeListener(this.onPreferenceChange); + } + onPreferenceChange() { + this.setState({ + ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value + }); } handleResize() { this.setState({ @@ -201,10 +211,12 @@ export default class CreatePost extends React.Component { ); } postMsgKeyPress(e) { - if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) { - e.preventDefault(); - ReactDOM.findDOMNode(this.refs.textbox).blur(); - this.handleSubmit(e); + if (this.state.ctrlSend === 'true' && e.ctrlKey || this.state.ctrlSend === 'false') { + if (e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) { + e.preventDefault(); + ReactDOM.findDOMNode(this.refs.textbox).blur(); + this.handleSubmit(e); + } } const t = Date.now(); @@ -328,7 +340,12 @@ export default class CreatePost extends React.Component { const draft = PostStore.getDraft(channelId); return draft.previews.length + draft.uploadsInProgress.length; } - handleArrowUp(e) { + handleKeyDown(e) { + if (this.state.ctrlSend === 'true' && e.keyCode === KeyCodes.ENTER && e.ctrlKey === true) { + this.postMsgKeyPress(e); + return; + } + if (e.keyCode === KeyCodes.UP && this.state.messageText === '') { e.preventDefault(); @@ -393,7 +410,7 @@ export default class CreatePost extends React.Component { <Textbox onUserInput={this.handleUserInput} onKeyPress={this.postMsgKeyPress} - onKeyDown={this.handleArrowUp} + onKeyDown={this.handleKeyDown} onHeightChange={this.resizePostHolder} messageText={this.state.messageText} createMessage='Write a message...' diff --git a/web/react/components/edit_channel_modal.jsx b/web/react/components/edit_channel_modal.jsx index d63a1db30..5b3c74e82 100644 --- a/web/react/components/edit_channel_modal.jsx +++ b/web/react/components/edit_channel_modal.jsx @@ -14,7 +14,7 @@ export default class EditChannelModal extends React.Component { this.onShow = this.onShow.bind(this); this.state = { - description: '', + header: '', title: '', channelId: '', serverError: '' @@ -28,32 +28,32 @@ export default class EditChannelModal extends React.Component { return; } - data.channel_description = this.state.description.trim(); + data.channel_header = this.state.header.trim(); - Client.updateChannelDesc(data, - function handleUpdateSuccess() { + Client.updateChannelHeader(data, + () => { this.setState({serverError: ''}); AsyncClient.getChannel(this.state.channelId); $(ReactDOM.findDOMNode(this.refs.modal)).modal('hide'); - }.bind(this), - function handleUpdateError(err) { - if (err.message === 'Invalid channel_description parameter') { - this.setState({serverError: 'This description is too long, please enter a shorter one'}); + }, + (err) => { + if (err.message === 'Invalid channel_header parameter') { + this.setState({serverError: 'This channel header is too long, please enter a shorter one'}); } else { this.setState({serverError: err.message}); } - }.bind(this) + } ); } handleUserInput(e) { - this.setState({description: e.target.value}); + this.setState({header: e.target.value}); } handleClose() { - this.setState({description: '', serverError: ''}); + this.setState({header: '', serverError: ''}); } onShow(e) { const button = e.relatedTarget; - this.setState({description: $(button).attr('data-desc'), title: $(button).attr('data-title'), channelId: $(button).attr('data-channelid'), serverError: ''}); + this.setState({header: $(button).attr('data-header'), title: $(button).attr('data-title'), channelId: $(button).attr('data-channelid'), serverError: ''}); } componentDidMount() { $(ReactDOM.findDOMNode(this.refs.modal)).on('show.bs.modal', this.onShow); @@ -73,7 +73,7 @@ export default class EditChannelModal extends React.Component { className='modal-title' ref='title' > - Edit Description + {'Edit Header'} </h4> ); if (this.state.title) { @@ -82,7 +82,7 @@ export default class EditChannelModal extends React.Component { className='modal-title' ref='title' > - Edit Description for <span className='name'>{this.state.title}</span> + {'Edit Header for '}<span className='name'>{this.state.title}</span> </h4> ); } @@ -105,17 +105,17 @@ export default class EditChannelModal extends React.Component { data-dismiss='modal' aria-label='Close' > - <span aria-hidden='true'>×</span> + <span aria-hidden='true'>{'×'}</span> </button> {editTitle} </div> <div className='modal-body'> + <p>{'Edit the text appearing next to the channel name in the channel header.'}</p> <textarea className='form-control no-resize' rows='6' - ref='channelDesc' maxLength='1024' - value={this.state.description} + value={this.state.header} onChange={this.handleUserInput} /> {serverError} @@ -126,14 +126,14 @@ export default class EditChannelModal extends React.Component { className='btn btn-default' data-dismiss='modal' > - Cancel + {'Cancel'} </button> <button type='button' className='btn btn-primary' onClick={this.handleEdit} > - Save + {'Save'} </button> </div> </div> diff --git a/web/react/components/edit_channel_purpose_modal.jsx b/web/react/components/edit_channel_purpose_modal.jsx new file mode 100644 index 000000000..4d162cfe7 --- /dev/null +++ b/web/react/components/edit_channel_purpose_modal.jsx @@ -0,0 +1,124 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const AsyncClient = require('../utils/async_client.jsx'); +const Client = require('../utils/client.jsx'); +const Modal = ReactBootstrap.Modal; + +export default class EditChannelPurposeModal extends React.Component { + constructor(props) { + super(props); + + this.handleHide = this.handleHide.bind(this); + this.handleSave = this.handleSave.bind(this); + + this.state = {serverError: ''}; + } + + handleHide() { + this.setState({serverError: ''}); + + if (this.props.onModalDismissed) { + this.props.onModalDismissed(); + } + } + + handleSave() { + if (!this.props.channel) { + return; + } + + const data = { + channel_id: this.props.channel.id, + channel_purpose: ReactDOM.findDOMNode(this.refs.purpose).value.trim() + }; + + Client.updateChannelPurpose(data, + () => { + AsyncClient.getChannel(this.props.channel.id); + + this.handleHide(); + }, + (err) => { + if (err.message === 'Invalid channel_purpose parameter') { + this.setState({serverError: 'This channel purpose is too long, please enter a shorter one'}); + } else { + this.setState({serverError: err.message}); + } + } + ); + } + + render() { + if (!this.props.show) { + return null; + } + + let serverError = null; + if (this.state.serverError) { + serverError = ( + <div className='form-group has-error'> + <br/> + <label className='control-label'>{this.state.serverError}</label> + </div> + ); + } + + let title = <span>{'Edit Purpose'}</span>; + if (this.props.channel.display_name) { + 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' + show={this.props.show} + onHide={this.handleHide} + > + <Modal.Header closeButton={true}> + <Modal.Title> + {title} + </Modal.Title> + </Modal.Header> + <Modal.Body> + <p>{`Describe how this ${channelTerm} should be used.`}</p> + <textarea + ref='purpose' + className='form-control no-resize' + rows='6' + maxLength='128' + defaultValue={this.props.channel.purpose} + /> + {serverError} + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={this.handleHide} + > + {'Cancel'} + </button> + <button + type='button' + className='btn btn-primary' + onClick={this.handleSave} + > + {'Save'} + </button> + </Modal.Footer> + </Modal> + ); + } +} + +EditChannelPurposeModal.propTypes = { + show: React.PropTypes.bool.isRequired, + channel: React.PropTypes.object, + onModalDismissed: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/edit_post_modal.jsx b/web/react/components/edit_post_modal.jsx index b259b3c18..2abb3f151 100644 --- a/web/react/components/edit_post_modal.jsx +++ b/web/react/components/edit_post_modal.jsx @@ -6,6 +6,10 @@ var AsyncClient = require('../utils/async_client.jsx'); var Textbox = require('./textbox.jsx'); var BrowserStore = require('../stores/browser_store.jsx'); var PostStore = require('../stores/post_store.jsx'); +var PreferenceStore = require('../stores/preference_store.jsx'); + +var Constants = require('../utils/constants.jsx'); +var KeyCodes = Constants.KeyCodes; export default class EditPostModal extends React.Component { constructor() { @@ -16,6 +20,8 @@ export default class EditPostModal extends React.Component { this.handleEditKeyPress = this.handleEditKeyPress.bind(this); this.handleUserInput = this.handleUserInput.bind(this); this.handleEditPostEvent = this.handleEditPostEvent.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.onPreferenceChange = this.onPreferenceChange.bind(this); this.state = {editText: '', title: '', post_id: '', channel_id: '', comments: 0, refocusId: ''}; } @@ -51,7 +57,7 @@ export default class EditPostModal extends React.Component { this.setState({editText: editMessage}); } handleEditKeyPress(e) { - if (e.which === 13 && !e.shiftKey && !e.altKey) { + if (this.state.ctrlSend === 'false' && e.which === KeyCodes.ENTER && !e.shiftKey && !e.altKey) { e.preventDefault(); ReactDOM.findDOMNode(this.refs.editbox).blur(); this.handleEdit(e); @@ -72,6 +78,16 @@ export default class EditPostModal extends React.Component { $(ReactDOM.findDOMNode(this.refs.modal)).modal('show'); } + handleKeyDown(e) { + if (this.state.ctrlSend === 'true' && e.keyCode === KeyCodes.ENTER && e.ctrlKey === true) { + this.handleEdit(e); + } + } + onPreferenceChange() { + this.setState({ + ctrlSend: PreferenceStore.getPreference(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', {value: 'false'}).value + }); + } componentDidMount() { var self = this; @@ -84,7 +100,7 @@ export default class EditPostModal extends React.Component { if (!button) { return; } - self.setState({editText: $(button).attr('data-message'), title: $(button).attr('data-title'), channel_id: $(button).attr('data-channelid'), post_id: $(button).attr('data-postid'), comments: $(button).attr('data-comments'), refocusId: $(button).attr('data-refoucsid')}); + self.setState({editText: $(button).attr('data-message'), title: $(button).attr('data-title'), channel_id: $(button).attr('data-channelid'), post_id: $(button).attr('data-postid'), comments: $(button).attr('data-comments'), refocusId: $(button).attr('data-refocusid')}); }); $(ReactDOM.findDOMNode(this.refs.modal)).on('shown.bs.modal', function onShown() { @@ -101,9 +117,11 @@ export default class EditPostModal extends React.Component { }); PostStore.addEditPostListener(this.handleEditPostEvent); + PreferenceStore.addChangeListener(this.onPreferenceChange); } componentWillUnmount() { PostStore.removeEditPostListener(this.handleEditPostEvent); + PreferenceStore.removeChangeListener(this.onPreferenceChange); } render() { var error = (<div className='form-group'><br /></div>); @@ -138,6 +156,7 @@ export default class EditPostModal extends React.Component { <Textbox onUserInput={this.handleEditInput} onKeyPress={this.handleEditKeyPress} + onKeyDown={this.handleKeyDown} messageText={this.state.editText} createMessage='Edit the post...' id='edit_textbox' diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx index 4d4e8390c..e707e32f5 100644 --- a/web/react/components/file_attachment.jsx +++ b/web/react/components/file_attachment.jsx @@ -270,7 +270,7 @@ export default class FileAttachment extends React.Component { href={fileUrl} download={filenameString} data-toggle='tooltip' - title={filenameString} + title={'Download ' + filenameString} className='post-image__name' > {trimmedFilename} diff --git a/web/react/components/get_link_modal.jsx b/web/react/components/get_link_modal.jsx index 325e86f3d..8839bc3c7 100644 --- a/web/react/components/get_link_modal.jsx +++ b/web/react/components/get_link_modal.jsx @@ -98,7 +98,7 @@ export default class GetLinkModal extends React.Component { <br /><br /> </p> <textarea - className='form-control no-resize' + className='form-control no-resize min-height' readOnly='true' ref='textarea' value={this.state.value} diff --git a/web/react/components/more_channels.jsx b/web/react/components/more_channels.jsx index a0084ad30..c4f831c2e 100644 --- a/web/react/components/more_channels.jsx +++ b/web/react/components/more_channels.jsx @@ -109,7 +109,7 @@ export default class MoreChannels extends React.Component { <tr key={channel.id}> <td> <p className='more-name'>{channel.display_name}</p> - <p className='more-description'>{channel.description}</p> + <p className='more-purpose'>{channel.purpose}</p> </td> <td className='td--action'> {joinButton} diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx index f9cd525fd..f7778f25f 100644 --- a/web/react/components/navbar.jsx +++ b/web/react/components/navbar.jsx @@ -8,6 +8,7 @@ var ChannelStore = require('../stores/channel_store.jsx'); var TeamStore = require('../stores/team_store.jsx'); var MessageWrapper = require('./message_wrapper.jsx'); var NotifyCounts = require('./notify_counts.jsx'); +const EditChannelPurposeModal = require('./edit_channel_purpose_modal.jsx'); const Utils = require('../utils/utils.jsx'); var Constants = require('../utils/constants.jsx'); @@ -26,7 +27,9 @@ export default class Navbar extends React.Component { this.createCollapseButtons = this.createCollapseButtons.bind(this); this.createDropdown = this.createDropdown.bind(this); - this.state = this.getStateFromStores(); + const state = this.getStateFromStores(); + state.showEditChannelPurposeModal = false; + this.state = state; } getStateFromStores() { return { @@ -106,22 +109,35 @@ export default class Navbar extends React.Component { </li> ); - var setChannelDescriptionOption = ( + var setChannelHeaderOption = ( <li role='presentation'> <a role='menuitem' href='#' data-toggle='modal' data-target='#edit_channel' - data-desc={channel.description} + data-header={channel.header} data-title={channel.display_name} data-channelid={channel.id} > - Set Channel Description... + Set Channel Header... </a> </li> ); + var setChannelPurposeOption = null; + if (!isDirect) { + setChannelPurposeOption = ( + <li role='presentation'> + <a + role='menuitem' + href='#' + onClick={() => this.setState({showEditChannelPurposeModal: true})} + /> + </li> + ); + } + var addMembersOption; var leaveChannelOption; if (!isDirect && !ChannelStore.isDefault(channel)) { @@ -249,7 +265,8 @@ export default class Navbar extends React.Component { {viewInfoOption} {addMembersOption} {manageMembersOption} - {setChannelDescriptionOption} + {setChannelHeaderOption} + {setChannelPurposeOption} {notificationPreferenceOption} {renameChannelOption} {deleteChannelOption} @@ -335,10 +352,10 @@ export default class Navbar extends React.Component { <Popover bsStyle='info' placement='bottom' - id='description-popover' + id='header-popover' > <MessageWrapper - message={channel.description} + message={channel.header} options={{singleline: true, mentionHighlight: false}} /> </Popover> @@ -360,20 +377,20 @@ export default class Navbar extends React.Component { } } - if (channel.description.length === 0) { + if (channel.header.length === 0) { popoverContent = ( <Popover bsStyle='info' placement='bottom' - id='description-popover' + id='header-popover' > <div> - {'No channel description yet.'} + {'No channel header yet.'} <br/> <a href='#' data-toggle='modal' - data-desc={channel.description} + data-header={channel.header} data-title={channel.display_name} data-channelid={channel.id} data-target='#edit_channel' @@ -392,17 +409,24 @@ export default class Navbar extends React.Component { var channelMenuDropdown = this.createDropdown(channel, channelTitle, isAdmin, isDirect, popoverContent); return ( - <nav - className='navbar navbar-default navbar-fixed-top' - role='navigation' - > - <div className='container-fluid theme'> - <div className='navbar-header'> - {collapseButtons} - {channelMenuDropdown} + <div> + <nav + className='navbar navbar-default navbar-fixed-top' + role='navigation' + > + <div className='container-fluid theme'> + <div className='navbar-header'> + {collapseButtons} + {channelMenuDropdown} + </div> </div> - </div> - </nav> + </nav> + <EditChannelPurposeModal + show={this.state.showEditChannelPurposeModal} + onModalDismissed={() => this.setState({showEditChannelPurposeModal: false})} + channel={channel} + /> + </div> ); } } diff --git a/web/react/components/navbar_dropdown.jsx b/web/react/components/navbar_dropdown.jsx index cc041e094..dc21fad21 100644 --- a/web/react/components/navbar_dropdown.jsx +++ b/web/react/components/navbar_dropdown.jsx @@ -58,6 +58,7 @@ export default class NavbarDropdown extends React.Component { TeamStore.addChangeListener(this.onListenerChange); $(ReactDOM.findDOMNode(this.refs.dropdown)).on('hide.bs.dropdown', () => { + $('.sidebar--left .dropdown-menu').scrollTop(0); this.blockToggle = true; setTimeout(() => { this.blockToggle = false; diff --git a/web/react/components/new_channel_flow.jsx b/web/react/components/new_channel_flow.jsx index 186cfc2b0..d6280d118 100644 --- a/web/react/components/new_channel_flow.jsx +++ b/web/react/components/new_channel_flow.jsx @@ -30,7 +30,7 @@ export default class NewChannelFlow extends React.Component { flowState: SHOW_NEW_CHANNEL, channelDisplayName: '', channelName: '', - channelDescription: '', + channelPurpose: '', nameModified: false }; } @@ -43,7 +43,7 @@ export default class NewChannelFlow extends React.Component { flowState: SHOW_NEW_CHANNEL, channelDisplayName: '', channelName: '', - channelDescription: '', + channelPurpose: '', nameModified: false }); } @@ -65,7 +65,7 @@ export default class NewChannelFlow extends React.Component { const cu = UserStore.getCurrentUser(); channel.team_id = cu.team_id; - channel.description = this.state.channelDescription; + channel.purpose = this.state.channelPurpose; channel.type = this.state.channelType; Client.createChannel(channel, @@ -109,7 +109,7 @@ export default class NewChannelFlow extends React.Component { channelDataChanged(data) { this.setState({ channelDisplayName: data.displayName, - channelDescription: data.description + channelPurpose: data.purpose }); if (!this.state.nameModified) { this.setState({channelName: Utils.cleanUpUrlable(data.displayName.trim())}); @@ -119,7 +119,7 @@ export default class NewChannelFlow extends React.Component { const channelData = { name: this.state.channelName, displayName: this.state.channelDisplayName, - description: this.state.channelDescription + purpose: this.state.channelPurpose }; let showChannelModal = false; diff --git a/web/react/components/new_channel_modal.jsx b/web/react/components/new_channel_modal.jsx index 4e6280c99..c0cea496f 100644 --- a/web/react/components/new_channel_modal.jsx +++ b/web/react/components/new_channel_modal.jsx @@ -36,7 +36,7 @@ export default class NewChannelModal extends React.Component { handleChange() { const newData = { displayName: ReactDOM.findDOMNode(this.refs.display_name).value, - description: ReactDOM.findDOMNode(this.refs.channel_desc).value + purpose: ReactDOM.findDOMNode(this.refs.channel_purpose).value }; this.props.onDataChanged(newData); } @@ -136,22 +136,22 @@ export default class NewChannelModal extends React.Component { </div> <div className='form-group less'> <div className='col-sm-3'> - <label className='form__label control-label'>{'Description'}</label> + <label className='form__label control-label'>{'Purpose'}</label> <label className='form__label light'>{'(optional)'}</label> </div> <div className='col-sm-9'> <textarea className='form-control no-resize' - ref='channel_desc' + ref='channel_purpose' rows='4' - placeholder='Description' - maxLength='1024' - value={this.props.channelData.description} + placeholder='Purpose' + maxLength='128' + value={this.props.channelData.purpose} onChange={this.handleChange} tabIndex='2' /> <p className='input__help'> - {'Description helps others decide whether to join this channel.'} + {`Describe how this ${channelTerm} should be used.`} </p> {serverError} </div> diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx index 9cffa2400..f3c0fa0b4 100644 --- a/web/react/components/popover_list_members.jsx +++ b/web/react/components/popover_list_members.jsx @@ -42,6 +42,10 @@ export default class PopoverListMembers extends React.Component { }; } + componentDidUpdate() { + $(ReactDOM.findDOMNode(this.refs.memebersPopover)).find('.popover-content').perfectScrollbar(); + } + handleShowDirectChannel(teammate, e) { e.preventDefault(); @@ -106,27 +110,27 @@ export default class PopoverListMembers extends React.Component { let button = ''; if (currentUserId !== m.id && ch.type !== 'D') { button = ( - <button - type='button' - className='btn btn-primary btn-message' + <a + href='#' + className='btn-message' onClick={(e) => this.handleShowDirectChannel(m, e)} > {'Message'} - </button> + </a> ); } if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) { popoverHtml.push( <div - className='text--nowrap' + className='text-nowrap' key={'popover-member-' + i} > <img - className='profile-img pull-left' - width='38' - height='38' + className='profile-img rounded pull-left' + width='26px' + height='26px' src={`/api/v1/users/${m.id}/image?time=${m.update_at}&${Utils.getSessionIndex()}`} /> <div className='pull-left'> @@ -135,14 +139,9 @@ export default class PopoverListMembers extends React.Component { > {m.username} </div> - <div - className='more-description' - > - {details} - </div> </div> <div - className='pull-right profile-action' + className='pull-right' > {button} </div> @@ -182,12 +181,11 @@ export default class PopoverListMembers extends React.Component { placement='bottom' > <Popover + ref='memebersPopover' title='Members' id='member-list-popover' > - <div> - {popoverHtml} - </div> + {popoverHtml} </Popover> </Overlay> </div> diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 45eae8c6a..7138e2cb4 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -297,7 +297,7 @@ export default class PostBody extends React.Component { } let embed; - if (filenames.length === 0 && this.state.links) { + if (filenames.length === 0 && this.state.links && this.state.links.length > 0) { embed = this.createEmbed(this.state.links[0]); } diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx index 36260d77c..6937ec216 100644 --- a/web/react/components/post_info.jsx +++ b/web/react/components/post_info.jsx @@ -44,7 +44,7 @@ export default class PostInfo extends React.Component { role='menuitem' data-toggle='modal' data-target='#edit_post' - data-refoucsid='#post_textbox' + data-refocusid='#post_textbox' data-title={type} data-message={post.message} data-postid={post.id} diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx index 3ceef478c..b9741bac4 100644 --- a/web/react/components/post_list.jsx +++ b/web/react/components/post_list.jsx @@ -9,6 +9,7 @@ const LoadingScreen = require('./loading_screen.jsx'); const PostStore = require('../stores/post_store.jsx'); const ChannelStore = require('../stores/channel_store.jsx'); const UserStore = require('../stores/user_store.jsx'); +const TeamStore = require('../stores/team_store.jsx'); const SocketStore = require('../stores/socket_store.jsx'); const PreferenceStore = require('../stores/preference_store.jsx'); @@ -358,11 +359,11 @@ export default class PostList extends React.Component { href='#' data-toggle='modal' data-target='#edit_channel' - data-desc={channel.description} + data-header={channel.header} data-title={channel.display_name} data-channelid={channel.id} > - <i className='fa fa-pencil'></i>{'Set a description'} + <i className='fa fa-pencil'></i>{'Set a header'} </a> </div> ); @@ -386,17 +387,55 @@ export default class PostList extends React.Component { } } createDefaultIntroMessage(channel) { + const team = TeamStore.getCurrent(); + let inviteModalLink; + if (team.type === Constants.INVITE_TEAM) { + inviteModalLink = ( + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#invite_member' + > + <i className='fa fa-user-plus'></i>{'Invite others to this team'} + </a> + ); + } else { + inviteModalLink = ( + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#get_link' + data-title='Team Invite' + data-value={Utils.getWindowLocationOrigin() + '/signup_user_complete/?id=' + team.id} + > + <i className='fa fa-user-plus'></i>{'Invite others to this team'} + </a> + ); + } + return ( <div className='channel-intro'> <h4 className='channel-intro__title'>{'Beginning of ' + channel.display_name}</h4> <p className='channel-intro__content'> - {'Welcome to ' + channel.display_name + '!'} + <strong>{'Welcome to ' + channel.display_name + '!'}</strong> <br/><br/> {'This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.'} - <br/><br/> - {'To create a new channel or join an existing one, go to the Left Sidebar under “Channels” and click “More…”.'} - <br/> </p> + {inviteModalLink} + <a + className='intro-links' + href='#' + data-toggle='modal' + data-target='#edit_channel' + data-header={channel.header} + data-title={channel.display_name} + data-channelid={channel.id} + > + <i className='fa fa-pencil'></i>{'Set a header'} + </a> + <br/> </div> ); } @@ -413,11 +452,11 @@ export default class PostList extends React.Component { href='#' data-toggle='modal' data-target='#edit_channel' - data-desc={channel.description} + data-header={channel.header} data-title={channel.display_name} data-channelid={channel.id} > - <i className='fa fa-pencil'></i>{'Set a description'} + <i className='fa fa-pencil'></i>{'Set a header'} </a> <a className='intro-links' @@ -479,11 +518,11 @@ export default class PostList extends React.Component { href='#' data-toggle='modal' data-target='#edit_channel' - data-desc={channel.description} + data-header={channel.header} data-title={channel.display_name} data-channelid={channel.id} > - <i className='fa fa-pencil'></i>{'Set a description'} + <i className='fa fa-pencil'></i>{'Set a header'} </a> <a className='intro-links' diff --git a/web/react/components/register_app_modal.jsx b/web/react/components/register_app_modal.jsx index 3d4d9bf45..c40409dcc 100644 --- a/web/react/components/register_app_modal.jsx +++ b/web/react/components/register_app_modal.jsx @@ -96,75 +96,74 @@ export default class RegisterAppModal extends React.Component { var body = ''; if (this.state.clientId === '') { body = ( - <div className='form-group user-settings'> - <h3>{'Register a New Application'}</h3> - <br/> - <label className='col-sm-4 control-label'>{'Application Name'}</label> - <div className='col-sm-7'> - <input - ref='name' - className='form-control' - type='text' - placeholder='Required' - /> - {nameError} - </div> - <br/> - <br/> - <label className='col-sm-4 control-label'>{'Homepage URL'}</label> - <div className='col-sm-7'> - <input - ref='homepage' - className='form-control' - type='text' - placeholder='Required' - /> - {homepageError} - </div> - <br/> - <br/> - <label className='col-sm-4 control-label'>{'Description'}</label> - <div className='col-sm-7'> - <input - ref='desc' - className='form-control' - type='text' - placeholder='Optional' - /> - </div> - <br/> - <br/> - <label className='col-sm-4 control-label'>{'Callback URL'}</label> - <div className='col-sm-7'> - <textarea - ref='callback' - className='form-control' - type='text' - placeholder='Required' - rows='5' - /> - {callbackError} + <div className='settings-modal'> + <div className='form-horizontal user-settings'> + <h4 className='padding-bottom x3'>{'Register a New Application'}</h4> + <div className='row'> + <label className='col-sm-4 control-label'>{'Application Name'}</label> + <div className='col-sm-7'> + <input + ref='name' + className='form-control' + type='text' + placeholder='Required' + /> + {nameError} + </div> + </div> + <div className='row padding-top x2'> + <label className='col-sm-4 control-label'>{'Homepage URL'}</label> + <div className='col-sm-7'> + <input + ref='homepage' + className='form-control' + type='text' + placeholder='Required' + /> + {homepageError} + </div> + </div> + <div className='row padding-top x2'> + <label className='col-sm-4 control-label'>{'Description'}</label> + <div className='col-sm-7'> + <input + ref='desc' + className='form-control' + type='text' + placeholder='Optional' + /> + </div> + </div> + <div className='row padding-top padding-bottom x2'> + <label className='col-sm-4 control-label'>{'Callback URL'}</label> + <div className='col-sm-7'> + <textarea + ref='callback' + className='form-control' + type='text' + placeholder='Required' + rows='5' + /> + {callbackError} + </div> + </div> + {serverError} + <hr /> + <a + className='btn btn-sm theme pull-right' + href='#' + data-dismiss='modal' + aria-label='Close' + > + {'Cancel'} + </a> + <a + className='btn btn-sm btn-primary pull-right' + onClick={this.register} + > + {'Register'} + </a> </div> - <br/> - <br/> - <br/> - <br/> - <br/> - {serverError} - <a - className='btn btn-sm theme pull-right' - href='#' - data-dismiss='modal' - aria-label='Close' - > - {'Cancel'} - </a> - <a - className='btn btn-sm btn-primary pull-right' - onClick={this.register} - > - {'Register'} - </a> </div> ); } else { diff --git a/web/react/components/rhs_comment.jsx b/web/react/components/rhs_comment.jsx index cfff04fa2..8c6324c72 100644 --- a/web/react/components/rhs_comment.jsx +++ b/web/react/components/rhs_comment.jsx @@ -93,6 +93,7 @@ export default class RhsComment extends React.Component { role='menuitem' data-toggle='modal' data-target='#edit_post' + data-refocusid='#reply_textbox' data-title='Comment' data-message={post.message} data-postid={post.id} diff --git a/web/react/components/rhs_root_post.jsx b/web/react/components/rhs_root_post.jsx index deef389e2..21e52b438 100644 --- a/web/react/components/rhs_root_post.jsx +++ b/web/react/components/rhs_root_post.jsx @@ -79,6 +79,7 @@ export default class RhsRootPost extends React.Component { role='menuitem' data-toggle='modal' data-target='#edit_post' + data-refocusid='#reply_textbox' data-title={type} data-message={post.message} data-postid={post.id} diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx index 03c7b894c..f7d772677 100644 --- a/web/react/components/search_autocomplete.jsx +++ b/web/react/components/search_autocomplete.jsx @@ -10,6 +10,7 @@ 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) { @@ -36,6 +37,11 @@ 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); + } + componentWillUnmount() { $(document).off('click', this.handleDocumentClick); } @@ -193,7 +199,7 @@ export default class SearchAutocomplete extends React.Component { if (this.state.mode === 'channels') { suggestions = this.state.suggestions.map((channel, index) => { - let className = 'search-autocomplete__channel'; + let className = 'search-autocomplete__item'; if (this.state.selection === index) { className += ' selected'; } @@ -211,7 +217,7 @@ export default class SearchAutocomplete extends React.Component { }); } else if (this.state.mode === 'users') { suggestions = this.state.suggestions.map((user, index) => { - let className = 'search-autocomplete__user'; + let className = 'search-autocomplete__item'; if (this.state.selection === index) { className += ' selected'; } @@ -224,7 +230,7 @@ export default class SearchAutocomplete extends React.Component { className={className} > <img - className='profile-img' + className='profile-img rounded' src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at} /> {user.username} @@ -234,12 +240,15 @@ export default class SearchAutocomplete extends React.Component { } return ( - <div - ref='container' - className='search-autocomplete' + <Popover + ref='searchPopover' + onShow={this.componentDidMount} + id='search-autocomplete__popover' + className='search-help-popover autocomplete visible' + placement='bottom' > {suggestions} - </div> + </Popover> ); } } diff --git a/web/react/components/setting_item_max.jsx b/web/react/components/setting_item_max.jsx index 774f98a43..d6c4b0d4b 100644 --- a/web/react/components/setting_item_max.jsx +++ b/web/react/components/setting_item_max.jsx @@ -35,8 +35,10 @@ export default class SettingItemMax extends React.Component { var widthClass; if (this.props.width === 'full') { widthClass = 'col-sm-12'; - } else { + } else if (this.props.width === 'medium') { widthClass = 'col-sm-10 col-sm-offset-2'; + } else { + widthClass = 'col-sm-9 col-sm-offset-3'; } return ( diff --git a/web/react/components/setting_picture.jsx b/web/react/components/setting_picture.jsx index b6bcb13a6..e69412cca 100644 --- a/web/react/components/setting_picture.jsx +++ b/web/react/components/setting_picture.jsx @@ -42,7 +42,7 @@ export default class SettingPicture extends React.Component { img = ( <img ref='image' - className='profile-img' + className='profile-img rounded' src='' /> ); @@ -50,7 +50,7 @@ export default class SettingPicture extends React.Component { img = ( <img ref='image' - className='profile-img' + className='profile-img rounded' src={this.props.src} /> ); diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx index de28a8374..65e4c6d7e 100644 --- a/web/react/components/sidebar_header.jsx +++ b/web/react/components/sidebar_header.jsx @@ -5,6 +5,9 @@ var NavbarDropdown = require('./navbar_dropdown.jsx'); var UserStore = require('../stores/user_store.jsx'); const Utils = require('../utils/utils.jsx'); +const Tooltip = ReactBootstrap.Tooltip; +const OverlayTrigger = ReactBootstrap.OverlayTrigger; + export default class SidebarHeader extends React.Component { constructor(props) { super(props); @@ -47,7 +50,15 @@ export default class SidebarHeader extends React.Component { {profilePicture} <div className='header__info'> <div className='user__name'>{'@' + me.username}</div> + <OverlayTrigger + trigger={['hover', 'focus']} + delayShow={1000} + placement='bottom' + overlay={<Tooltip id='team-name__tooltip'>{this.props.teamDisplayName}</Tooltip>} + ref='descriptionOverlay' + > <div className='team__name'>{this.props.teamDisplayName}</div> + </OverlayTrigger> </div> </a> <NavbarDropdown diff --git a/web/react/components/team_signup_with_email.jsx b/web/react/components/team_signup_with_email.jsx index ff4ccd4d8..021713f04 100644 --- a/web/react/components/team_signup_with_email.jsx +++ b/web/react/components/team_signup_with_email.jsx @@ -71,7 +71,7 @@ export default class EmailSignUpPage extends React.Component { className='btn btn-md btn-primary' type='submit' > - {'Sign up'} + {'Create Team'} </button> {serverError} </div> diff --git a/web/react/components/user_settings/custom_theme_chooser.jsx b/web/react/components/user_settings/custom_theme_chooser.jsx index 44b3f4544..095e5b622 100644 --- a/web/react/components/user_settings/custom_theme_chooser.jsx +++ b/web/react/components/user_settings/custom_theme_chooser.jsx @@ -40,11 +40,12 @@ export default class CustomThemeChooser extends React.Component { const theme = {type: 'custom'}; let index = 0; Constants.THEME_ELEMENTS.forEach((element) => { - if (index < colors.length) { + if (index < colors.length - 1) { theme[element.id] = colors[index]; } index++; }); + theme.codeTheme = colors[colors.length - 1]; this.props.updateTheme(theme); } @@ -78,6 +79,8 @@ export default class CustomThemeChooser extends React.Component { colors += theme[element.id] + ','; }); + colors += theme.codeTheme; + const pasteBox = ( <div className='col-sm-12'> <label className='custom-label'> diff --git a/web/react/components/user_settings/manage_outgoing_hooks.jsx b/web/react/components/user_settings/manage_outgoing_hooks.jsx index 6e9b2205d..4c56db0a1 100644 --- a/web/react/components/user_settings/manage_outgoing_hooks.jsx +++ b/web/react/components/user_settings/manage_outgoing_hooks.jsx @@ -236,6 +236,7 @@ export default class ManageOutgoingHooks extends React.Component { return ( <div key='addOutgoingHook'> <label className='control-label'>{'Add a new outgoing webhook'}</label> + <div className='padding-top divider-light'></div> <div className='padding-top'> <div> <label className='control-label'>{'Channel'}</label> diff --git a/web/react/components/user_settings/user_settings.jsx b/web/react/components/user_settings/user_settings.jsx index 15bf961d6..546e26ca3 100644 --- a/web/react/components/user_settings/user_settings.jsx +++ b/web/react/components/user_settings/user_settings.jsx @@ -10,6 +10,7 @@ var AppearanceTab = require('./user_settings_appearance.jsx'); var DeveloperTab = require('./user_settings_developer.jsx'); var IntegrationsTab = require('./user_settings_integrations.jsx'); var DisplayTab = require('./user_settings_display.jsx'); +var AdvancedTab = require('./user_settings_advanced.jsx'); export default class UserSettings extends React.Component { constructor(props) { @@ -110,6 +111,17 @@ export default class UserSettings extends React.Component { /> </div> ); + } else if (this.props.activeTab === 'advanced') { + return ( + <div> + <AdvancedTab + user={this.state.user} + activeSection={this.props.activeSection} + updateSection={this.props.updateSection} + updateTab={this.props.updateTab} + /> + </div> + ); } return <div/>; diff --git a/web/react/components/user_settings/user_settings_advanced.jsx b/web/react/components/user_settings/user_settings_advanced.jsx new file mode 100644 index 000000000..910444735 --- /dev/null +++ b/web/react/components/user_settings/user_settings_advanced.jsx @@ -0,0 +1,169 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const Client = require('../../utils/client.jsx'); +const SettingItemMin = require('../setting_item_min.jsx'); +const SettingItemMax = require('../setting_item_max.jsx'); +const Constants = require('../../utils/constants.jsx'); +const PreferenceStore = require('../../stores/preference_store.jsx'); + +export default class AdvancedSettingsDisplay extends React.Component { + constructor(props) { + super(props); + + this.updateSection = this.updateSection.bind(this); + this.updateSetting = this.updateSetting.bind(this); + this.handleClose = this.handleClose.bind(this); + this.setupInitialState = this.setupInitialState.bind(this); + + this.state = this.setupInitialState(); + } + + setupInitialState() { + const sendOnCtrlEnter = PreferenceStore.getPreference( + Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, + 'send_on_ctrl_enter', + {value: 'false'} + ).value; + + return { + settings: {send_on_ctrl_enter: sendOnCtrlEnter} + }; + } + + updateSetting(setting, value) { + const settings = this.state.settings; + settings[setting] = value; + this.setState(settings); + } + + handleSubmit(setting) { + const preference = PreferenceStore.setPreference( + Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, + setting, + this.state.settings[setting] + ); + + Client.savePreferences([preference], + () => { + PreferenceStore.emitChange(); + this.updateSection(''); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + updateSection(section) { + this.props.updateSection(section); + } + + handleClose() { + this.updateSection(''); + } + + componentDidMount() { + $('#user_settings').on('hidden.bs.modal', this.handleClose); + } + + componentWillUnmount() { + $('#user_settings').off('hidden.bs.modal', this.handleClose); + } + + render() { + const serverError = this.state.serverError || null; + let ctrlSendSection; + + if (this.props.activeSection === 'advancedCtrlSend') { + const ctrlSendActive = [ + this.state.settings.send_on_ctrl_enter === 'true', + this.state.settings.send_on_ctrl_enter === 'false' + ]; + + const inputs = [ + <div key='ctrlSendSetting'> + <div className='radio'> + <label> + <input + type='radio' + checked={ctrlSendActive[0]} + onChange={this.updateSetting.bind(this, 'send_on_ctrl_enter', 'true')} + /> + {'On'} + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={ctrlSendActive[1]} + onChange={this.updateSetting.bind(this, 'send_on_ctrl_enter', 'false')} + /> + {'Off'} + </label> + <br/> + </div> + <div><br/>{'If enabled \'Enter\' inserts a new line and \'Ctrl + Enter\' submits the message.'}</div> + </div> + ]; + + ctrlSendSection = ( + <SettingItemMax + title='Send messages on Ctrl + Enter' + inputs={inputs} + submit={() => this.handleSubmit('send_on_ctrl_enter')} + server_error={serverError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + ctrlSendSection = ( + <SettingItemMin + title='Send messages on Ctrl + Enter' + describe={this.state.settings.send_on_ctrl_enter === 'true' ? 'On' : 'Off'} + updateSection={() => this.props.updateSection('advancedCtrlSend')} + /> + ); + } + + return ( + <div> + <div className='modal-header'> + <button + type='button' + className='close' + data-dismiss='modal' + aria-label='Close' + > + <span aria-hidden='true'>{'×'}</span> + </button> + <h4 + className='modal-title' + ref='title' + > + <i className='modal-back'></i> + {'Advanced Settings'} + </h4> + </div> + <div className='user-settings'> + <h3 className='tab-header'>{'Advanced Settings'}</h3> + <div className='divider-dark first'/> + {ctrlSendSection} + <div className='divider-dark'/> + </div> + </div> + ); + } +} + +AdvancedSettingsDisplay.propTypes = { + user: React.PropTypes.object, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + activeSection: React.PropTypes.string +}; diff --git a/web/react/components/user_settings/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx index e94894a1d..7b4b54e27 100644 --- a/web/react/components/user_settings/user_settings_appearance.jsx +++ b/web/react/components/user_settings/user_settings_appearance.jsx @@ -100,7 +100,9 @@ export default class UserSettingsAppearance extends React.Component { ); } updateTheme(theme) { - theme.codeTheme = this.state.theme.codeTheme; + if (!theme.codeTheme) { + theme.codeTheme = this.state.theme.codeTheme; + } this.setState({theme}); Utils.applyTheme(theme); } diff --git a/web/react/components/user_settings/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx index 1c8ce3c79..3adac197a 100644 --- a/web/react/components/user_settings/user_settings_general.jsx +++ b/web/react/components/user_settings/user_settings_general.jsx @@ -570,6 +570,7 @@ export default class UserSettingsGeneralTab extends React.Component { /> ); } + return ( <div> <div className='modal-header'> diff --git a/web/react/components/user_settings/user_settings_integrations.jsx b/web/react/components/user_settings/user_settings_integrations.jsx index 4b1e5e532..9bee74343 100644 --- a/web/react/components/user_settings/user_settings_integrations.jsx +++ b/web/react/components/user_settings/user_settings_integrations.jsx @@ -43,6 +43,7 @@ export default class UserSettingsIntegrationsTab extends React.Component { incomingHooksSection = ( <SettingItemMax title='Incoming Webhooks' + width='medium' inputs={inputs} updateSection={(e) => { this.updateSection(''); @@ -54,6 +55,7 @@ export default class UserSettingsIntegrationsTab extends React.Component { incomingHooksSection = ( <SettingItemMin title='Incoming Webhooks' + width='medium' describe='Manage your incoming webhooks (Developer feature)' updateSection={() => { this.updateSection('incoming-hooks'); @@ -72,6 +74,7 @@ export default class UserSettingsIntegrationsTab extends React.Component { outgoingHooksSection = ( <SettingItemMax title='Outgoing Webhooks' + width='medium' inputs={inputs} updateSection={(e) => { this.updateSection(''); @@ -83,6 +86,7 @@ export default class UserSettingsIntegrationsTab extends React.Component { outgoingHooksSection = ( <SettingItemMin title='Outgoing Webhooks' + width='medium' describe='Manage your outgoing webhooks' updateSection={() => { this.updateSection('outgoing-hooks'); diff --git a/web/react/components/user_settings/user_settings_modal.jsx b/web/react/components/user_settings/user_settings_modal.jsx index 5449ae91e..18dd490e7 100644 --- a/web/react/components/user_settings/user_settings_modal.jsx +++ b/web/react/components/user_settings/user_settings_modal.jsx @@ -43,6 +43,7 @@ export default class UserSettingsModal extends React.Component { tabs.push({name: 'integrations', uiName: 'Integrations', icon: 'glyphicon glyphicon-transfer'}); } tabs.push({name: 'display', uiName: 'Display', icon: 'glyphicon glyphicon-eye-open'}); + tabs.push({name: 'advanced', uiName: 'Advanced', icon: 'glyphicon glyphicon-list-alt'}); return ( <div diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index a58fdde3a..8f4e30e7c 100644 --- a/web/react/stores/post_store.jsx +++ b/web/react/stores/post_store.jsx @@ -172,14 +172,15 @@ class PostStoreClass extends EventEmitter { var lastPost = null; for (i; i < len; i++) { - if (postList.posts[postList.order[i]].user_id === userId) { + let post = postList.posts[postList.order[i]]; + if (post.user_id === userId && (post.props && !post.props.from_webhook || !post.props)) { if (rootId) { - if (postList.posts[postList.order[i]].root_id === rootId || postList.posts[postList.order[i]].id === rootId) { - lastPost = postList.posts[postList.order[i]]; + if (post.root_id === rootId || post.id === rootId) { + lastPost = post; break; } } else { - lastPost = postList.posts[postList.order[i]]; + lastPost = post; break; } } diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index e1dee9c65..7ce1346f9 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -589,21 +589,38 @@ export function updateChannel(channel, success, error) { track('api', 'api_channels_update'); } -export function updateChannelDesc(data, success, error) { +export function updateChannelHeader(data, success, error) { $.ajax({ - url: '/api/v1/channels/update_desc', + url: '/api/v1/channels/update_header', dataType: 'json', contentType: 'application/json', type: 'POST', data: JSON.stringify(data), success, error: function onError(xhr, status, err) { - var e = handleError('updateChannelDesc', xhr, status, err); + var e = handleError('updateChannelHeader', xhr, status, err); error(e); } }); - track('api', 'api_channels_desc'); + track('api', 'api_channels_header'); +} + +export function updateChannelPurpose(data, success, error) { + $.ajax({ + url: '/api/v1/channels/update_purpose', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(data), + success, + error: function onError(xhr, status, err) { + var e = handleError('updateChannelPurpose', xhr, status, err); + error(e); + } + }); + + track('api', 'api_channels_purpose'); } export function updateNotifyProps(data, success, error) { diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 0e89b9470..1593f6706 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -127,6 +127,8 @@ module.exports = { MAX_DMS: 20, DM_CHANNEL: 'D', OPEN_CHANNEL: 'O', + INVITE_TEAM: 'I', + OPEN_TEAM: 'O', MAX_POST_LEN: 4000, EMOJI_SIZE: 16, ONLINE_ICON_SVG: "<svg version='1.1' id='Layer_1' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns#' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:svg='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' sodipodi:docname='TRASH_1_4.svg' inkscape:version='0.48.4 r9939' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='12px' height='12px' viewBox='0 0 12 12' enable-background='new 0 0 12 12' xml:space='preserve'><sodipodi:namedview inkscape:cy='139.7898' inkscape:cx='26.358185' inkscape:zoom='1.18' showguides='true' showgrid='false' id='namedview6' guidetolerance='10' gridtolerance='10' objecttolerance='10' borderopacity='1' bordercolor='#666666' pagecolor='#ffffff' inkscape:current-layer='Layer_1' inkscape:window-maximized='1' inkscape:window-y='-8' inkscape:window-x='-8' inkscape:window-height='705' inkscape:window-width='1366' inkscape:guide-bbox='true' inkscape:pageshadow='2' inkscape:pageopacity='0'><sodipodi:guide position='50.036793,85.991376' orientation='1,0' id='guide2986'></sodipodi:guide><sodipodi:guide position='58.426196,66.216355' orientation='0,1' id='guide3047'></sodipodi:guide></sodipodi:namedview><g><g><path class='online--icon' d='M6,5.487c1.371,0,2.482-1.116,2.482-2.493c0-1.378-1.111-2.495-2.482-2.495S3.518,1.616,3.518,2.994C3.518,4.371,4.629,5.487,6,5.487z M10.452,8.545c-0.101-0.829-0.36-1.968-0.726-2.541C9.475,5.606,8.5,5.5,8.5,5.5S8.43,7.521,6,7.521C3.507,7.521,3.5,5.5,3.5,5.5S2.527,5.606,2.273,6.004C1.908,6.577,1.648,7.716,1.547,8.545C1.521,8.688,1.49,9.082,1.498,9.142c0.161,1.295,2.238,2.322,4.375,2.358C5.916,11.501,5.958,11.501,6,11.501c0.043,0,0.084,0,0.127-0.001c2.076-0.026,4.214-1.063,4.375-2.358C10.509,9.082,10.471,8.696,10.452,8.545z'/></g></g></svg>", @@ -311,7 +313,8 @@ module.exports = { DEFAULT_CODE_THEME: 'github', Preferences: { CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show', - CATEGORY_DISPLAY_SETTINGS: 'display_settings' + CATEGORY_DISPLAY_SETTINGS: 'display_settings', + CATEGORY_ADVANCED_SETTINGS: 'advanced_settings' }, KeyCodes: { UP: 38, diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx index ad11a95ac..179416ea0 100644 --- a/web/react/utils/markdown.jsx +++ b/web/react/utils/markdown.jsx @@ -34,7 +34,43 @@ const highlightJsIni = require('highlight.js/lib/languages/ini.js'); const Constants = require('../utils/constants.jsx'); const HighlightedLanguages = Constants.HighlightedLanguages; -export class MattermostMarkdownRenderer extends marked.Renderer { +class MattermostInlineLexer extends marked.InlineLexer { + constructor(links, options) { + super(links, options); + + this.rules = Object.assign({}, this.rules); + + // modified version of the regex that doesn't break up words in snake_case, + // allows for links starting with www, and allows links succounded by parentheses + // the original is /^[\s\S]+?(?=[\\<!\[_*`~]|https?:\/\/| {2,}\n|$)/ + this.rules.text = /^[\s\S]+?(?=[^\w\/]_|[\\<!\[*`~]|https?:\/\/|www\.|\(| {2,}\n|$)/; + + // modified version of the regex that allows links starting with www and those surrounded + // by parentheses + // the original is /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/ + this.rules.url = /^(\(?(?:https?:\/\/|www\.)[^\s<.][^\s<]*[^<.,:;"'\]\s])/; + + // modified version of the regex that allows <links> starting with www. + // the original is /^<([^ >]+(@|:\/)[^ >]+)>/ + this.rules.autolink = /^<((?:[^ >]+(@|:\/)|www\.)[^ >]+)>/; + } +} + +class MattermostParser extends marked.Parser { + parse(src) { + this.inline = new MattermostInlineLexer(src.links, this.options, this.renderer); + this.tokens = src.reverse(); + + var out = ''; + while (this.next()) { + out += this.tok(); + } + + return out; + } +} + +class MattermostMarkdownRenderer extends marked.Renderer { constructor(options, formattingOptions = {}) { super(options); @@ -70,15 +106,21 @@ export class MattermostMarkdownRenderer extends marked.Renderer { } code(code, language) { - if (!language || highlightJs.listLanguages().indexOf(language) < 0) { - let parsed = super.code(code, language); - return '<code class="hljs">' + $(parsed).text() + '</code>'; + let usedLanguage = language; + + if (String(usedLanguage).toLocaleLowerCase() === 'html') { + usedLanguage = 'xml'; } - let parsed = highlightJs.highlight(language, code); + 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>'; + } + + let parsed = highlightJs.highlight(usedLanguage, code); return '<div class="post-body--code">' + - '<span class="post-body--code__language">' + HighlightedLanguages[language] + '</span>' + - '<code style="white-space: pre;" class="hljs">' + parsed.value + '</code>' + + '<span class="post-body--code__language">' + HighlightedLanguages[usedLanguage] + '</span>' + + '<code class="hljs">' + parsed.value + '</code>' + '</div>'; } @@ -97,8 +139,20 @@ export class MattermostMarkdownRenderer extends marked.Renderer { link(href, title, text) { let outHref = href; + let outText = text; + let prefix = ''; + let suffix = ''; + + // some links like https://en.wikipedia.org/wiki/Rendering_(computer_graphics) contain brackets + // and we try our best to differentiate those from ones just wrapped in brackets when autolinking + if (outHref.startsWith('(') && outHref.endsWith(')') && text === outHref) { + prefix = '('; + suffix = ')'; + outText = text.substring(1, text.length - 1); + outHref = outHref.substring(1, outHref.length - 1); + } - if (!(/^(mailto|https?|ftp)/.test(outHref))) { + if (!(/[a-z+.-]+:/i).test(outHref)) { outHref = `http://${outHref}`; } @@ -113,26 +167,17 @@ export class MattermostMarkdownRenderer extends marked.Renderer { output += ' target="_blank">'; } - output += text + '</a>'; + output += outText + '</a>'; - return output; + return prefix + output + suffix; } paragraph(text) { - let outText = text; - - // required so markdown does not strip '_' from @user_names - outText = TextFormatting.doFormatMentions(text); - - if (!('emoticons' in this.options) || this.options.emoticon) { - outText = TextFormatting.doFormatEmoticons(outText); - } - if (this.formattingOptions.singleline) { - return `<p class="markdown__paragraph-inline">${outText}</p>`; + return `<p class="markdown__paragraph-inline">${text}</p>`; } - return super.paragraph(outText); + return super.paragraph(text); } table(header, body) { @@ -143,3 +188,16 @@ export class MattermostMarkdownRenderer extends marked.Renderer { return TextFormatting.doFormatText(txt, this.formattingOptions); } } + +export function format(text, options) { + const markdownOptions = { + renderer: new MattermostMarkdownRenderer(null, options), + sanitize: true, + gfm: true + }; + + const tokens = marked.lexer(text, markdownOptions); + + return new MattermostParser(markdownOptions).parse(tokens); +} + diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx index 9f1a5a53f..2de858a17 100644 --- a/web/react/utils/text_formatting.jsx +++ b/web/react/utils/text_formatting.jsx @@ -8,8 +8,6 @@ const Markdown = require('./markdown.jsx'); const UserStore = require('../stores/user_store.jsx'); const Utils = require('./utils.jsx'); -const marked = require('marked'); - // Performs formatting of user posts including highlighting mentions and search terms and converting urls, hashtags, and // @mentions to links by taking a user's message and returning a string of formatted html. Also takes a number of options // as part of the second parameter: @@ -22,11 +20,8 @@ export function formatText(text, options = {}) { let output; if (!('markdown' in options) || options.markdown) { - // the markdown renderer will call doFormatText as necessary so just call marked - output = marked(text, { - renderer: new Markdown.MattermostMarkdownRenderer(null, options), - sanitize: true - }); + // the markdown renderer will call doFormatText as necessary + output = Markdown.format(text, options); } else { output = sanitizeHtml(text); output = doFormatText(output, options); @@ -48,7 +43,7 @@ export function doFormatText(text, options) { // replace important words and phrases with tokens output = autolinkAtMentions(output, tokens); - output = autolinkUrls(output, tokens); + output = autolinkEmails(output, tokens); output = autolinkHashtags(output, tokens); if (!('emoticons' in options) || options.emoticon) { @@ -98,28 +93,21 @@ export function sanitizeHtml(text) { return output; } -// Convert URLs into tokens -function autolinkUrls(text, tokens) { - function replaceUrlWithToken(autolinker, match) { +// Convert emails into tokens +function autolinkEmails(text, tokens) { + function replaceEmailWithToken(autolinker, match) { const linkText = match.getMatchedText(); let url = linkText; if (match.getType() === 'email') { url = `mailto:${url}`; - } else if (!(/^(mailto|https?|ftp)/.test(url))) { - url = `http://${url}`; } const index = tokens.size; - const alias = `MM_LINK${index}`; - - var target = 'target="_blank"'; - if (url.lastIndexOf(Utils.getTeamURLFromAddressBar(), 0) === 0) { - target = ''; - } + const alias = `MM_EMAIL${index}`; tokens.set(alias, { - value: `<a class="theme" ${target} href="${url}">${linkText}</a>`, + value: `<a class="theme" href="${url}">${linkText}</a>`, originalText: linkText }); @@ -128,12 +116,12 @@ function autolinkUrls(text, tokens) { // we can't just use a static autolinker because we need to set replaceFn const autolinker = new Autolinker({ - urls: true, + urls: false, email: true, phone: false, twitter: false, hashtag: false, - replaceFn: replaceUrlWithToken + replaceFn: replaceEmailWithToken }); return autolinker.link(text); diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 3140a5d77..c7c8549b9 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -231,46 +231,62 @@ export function getTimestamp() { return Date.now(); } -function testUrlMatch(text) { - var urlMatcher = new Autolinker.matchParser.MatchParser({ +// extracts links not styled by Markdown +export function extractLinks(text) { + const urlMatcher = new Autolinker.matchParser.MatchParser({ urls: true, emails: false, twitter: false, phone: false, hashtag: false }); - var result = []; + const links = []; + let replaceText = text; + + // pull out the Markdown code blocks + const codeBlocks = []; + const splitText = replaceText.split('`'); // also handles ``` + for (let i = 1; i < splitText.length; i += 2) { + if (splitText[i].trim() !== '') { + codeBlocks.push(splitText[i]); + } + } + function replaceFn(match) { - var linkData = {}; - var matchText = match.getMatchedText(); + let link = ''; + const matchText = match.getMatchedText(); + const tempText = replaceText; + + const start = replaceText.indexOf(matchText); + const end = start + matchText.length; + + replaceText = replaceText.substring(0, start) + replaceText.substring(end); + + // if it's a Markdown link, just skip it + if (start > 1) { + if (tempText.charAt(start - 2) === ']' && tempText.charAt(start - 1) === '(' && tempText.charAt(end) === ')') { + return; + } + } + + // if it's in a Markdown code block, skip it + for (const i in codeBlocks) { + if (codeBlocks[i].indexOf(matchText) === 0) { + codeBlocks[i] = codeBlocks[i].replace(matchText, ''); + return; + } + } - linkData.text = matchText; if (matchText.trim().indexOf('http') === 0) { - linkData.link = matchText; + link = matchText; } else { - linkData.link = 'http://' + matchText; + link = 'http://' + matchText; } - result.push(linkData); + links.push(link); } urlMatcher.replace(text, replaceFn, this); - return result; -} - -export function extractLinks(text) { - var repRegex = new RegExp('<br>', 'g'); - var matches = testUrlMatch(text.replace(repRegex, '\n')); - - if (!matches.length) { - return {links: null, text: text}; - } - - var links = []; - for (var i = 0; i < matches.length; i++) { - links.push(matches[i].link); - } - - return {links: links, text: text}; + return {links, text}; } export function escapeRegExp(string) { @@ -443,6 +459,8 @@ export function applyTheme(theme) { if (theme.sidebarTextActiveColor) { changeCss('.sidebar--left .nav-pills__container li.active a, .sidebar--left .nav-pills__container li.active a:hover, .sidebar--left .nav-pills__container li.active a:focus, .settings-modal .nav-pills>li.active a, .settings-modal .nav-pills>li.active a:hover, .settings-modal .nav-pills>li.active a:active', 'color:' + theme.sidebarTextActiveColor, 2); changeCss('.sidebar--left .nav li.active a, .sidebar--left .nav li.active a:hover, .sidebar--left .nav li.active a:focus', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.1), 1); + changeCss('.search-help-popover .search-autocomplete__item:hover', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.05), 1); + changeCss('.search-help-popover .search-autocomplete__item.selected', 'background:' + changeOpacity(theme.sidebarTextActiveColor, 0.15), 1); } if (theme.sidebarHeaderBg) { |