diff options
Diffstat (limited to 'webapp/components')
-rw-r--r-- | webapp/components/channel_select.jsx | 3 | ||||
-rw-r--r-- | webapp/components/channel_switch_modal.jsx | 214 | ||||
-rw-r--r-- | webapp/components/navbar.jsx | 58 | ||||
-rw-r--r-- | webapp/components/quick_switch_modal/index.js | 16 | ||||
-rw-r--r-- | webapp/components/quick_switch_modal/quick_switch_modal.jsx | 322 | ||||
-rw-r--r-- | webapp/components/sidebar.jsx | 25 | ||||
-rw-r--r-- | webapp/components/suggestion/provider.jsx | 5 | ||||
-rw-r--r-- | webapp/components/suggestion/suggestion_box.jsx | 95 | ||||
-rw-r--r-- | webapp/components/suggestion/suggestion_list.jsx | 22 | ||||
-rw-r--r-- | webapp/components/suggestion/switch_channel_provider.jsx | 248 | ||||
-rw-r--r-- | webapp/components/suggestion/switch_team_provider.jsx | 96 |
11 files changed, 776 insertions, 328 deletions
diff --git a/webapp/components/channel_select.jsx b/webapp/components/channel_select.jsx index b1b0f0276..bad8dffca 100644 --- a/webapp/components/channel_select.jsx +++ b/webapp/components/channel_select.jsx @@ -1,9 +1,8 @@ -import PropTypes from 'prop-types'; - // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import React from 'react'; +import PropTypes from 'prop-types'; import Constants from 'utils/constants.jsx'; import ChannelStore from 'stores/channel_store.jsx'; diff --git a/webapp/components/channel_switch_modal.jsx b/webapp/components/channel_switch_modal.jsx deleted file mode 100644 index 0f1fc4252..000000000 --- a/webapp/components/channel_switch_modal.jsx +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import SuggestionList from './suggestion/suggestion_list.jsx'; -import SuggestionBox from './suggestion/suggestion_box.jsx'; -import SwitchChannelProvider from './suggestion/switch_channel_provider.jsx'; - -import {FormattedMessage} from 'react-intl'; -import {Modal} from 'react-bootstrap'; - -import {goToChannel, openDirectChannelToUser} from 'actions/channel_actions.jsx'; - -import ChannelStore from 'stores/channel_store.jsx'; -import UserStore from 'stores/user_store.jsx'; - -import Constants from 'utils/constants.jsx'; -import * as Utils from 'utils/utils.jsx'; - -import PropTypes from 'prop-types'; - -import React from 'react'; -import $ from 'jquery'; - -export default class SwitchChannelModal extends React.Component { - constructor() { - super(); - - this.onChange = this.onChange.bind(this); - this.onItemSelected = this.onItemSelected.bind(this); - this.onShow = this.onShow.bind(this); - this.onHide = this.onHide.bind(this); - this.onExited = this.onExited.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - this.switchToChannel = this.switchToChannel.bind(this); - - this.suggestionProviders = [new SwitchChannelProvider()]; - - this.state = { - text: '', - error: '' - }; - } - - componentDidUpdate(prevProps) { - if (this.props.show && !prevProps.show) { - const textbox = this.refs.search.getTextbox(); - textbox.focus(); - Utils.placeCaretAtEnd(textbox); - } - } - - onShow() { - this.setState({ - text: '', - error: '' - }); - } - - onHide() { - this.setState({ - text: '', - error: '' - }); - this.props.onHide(); - } - - onExited() { - this.selected = null; - setTimeout(() => { - $('#post_textbox').get(0).focus(); - }); - } - - onChange(e) { - this.setState({text: e.target.value}); - this.selected = null; - } - - onItemSelected(item) { - this.selected = item; - } - - handleKeyDown(e) { - this.setState({ - error: '' - }); - if (e.keyCode === Constants.KeyCodes.ENTER) { - this.handleSubmit(); - } - } - - handleSubmit() { - let channel = null; - - if (!this.selected) { - if (this.state.text !== '') { - this.setState({ - error: Utils.localizeMessage('channel_switch_modal.not_found', 'No matches found.') - }); - } - return; - } - - if (this.selected.type === Constants.DM_CHANNEL) { - const user = UserStore.getProfileByUsername(this.selected.name); - - if (user) { - openDirectChannelToUser( - user.id, - (ch) => { - channel = ch; - this.switchToChannel(channel); - }, - () => { - channel = null; - this.switchToChannel(channel); - } - ); - } - } else { - channel = ChannelStore.get(this.selected.id); - this.switchToChannel(channel); - } - } - - switchToChannel(channel) { - if (channel !== null) { - goToChannel(channel); - this.onHide(); - } else if (this.state.text !== '') { - this.setState({ - error: Utils.localizeMessage('channel_switch_modal.failed_to_open', 'Failed to open channel.') - }); - } - } - - render() { - const message = this.state.error; - return ( - <Modal - dialogClassName='channel-switch-modal modal--overflow' - ref='modal' - show={this.props.show} - onHide={this.onHide} - onExited={this.onExited} - > - <Modal.Header closeButton={true}> - <Modal.Title> - <span> - <FormattedMessage - id='channel_switch_modal.title' - defaultMessage='Switch Channels' - /> - </span> - </Modal.Title> - </Modal.Header> - - <Modal.Body> - <div className='modal__hint'> - <FormattedMessage - id='channel_switch_modal.help' - defaultMessage='Type channel name. Use ↑↓ to browse, TAB to select, ↵ to confirm, ESC to dismiss' - /> - </div> - <SuggestionBox - ref='search' - className='form-control focused' - type='input' - onChange={this.onChange} - value={this.state.text} - onKeyDown={this.handleKeyDown} - onItemSelected={this.onItemSelected} - listComponent={SuggestionList} - maxLength='64' - providers={this.suggestionProviders} - listStyle='bottom' - /> - </Modal.Body> - <Modal.Footer> - <div className='modal__error'> - {message} - </div> - <button - type='button' - className='btn btn-default' - onClick={this.onHide} - > - <FormattedMessage - id='edit_channel_header_modal.cancel' - defaultMessage='Cancel' - /> - </button> - <button - type='button' - className='btn btn-primary' - onClick={this.handleSubmit} - > - <FormattedMessage - id='channel_switch_modal.submit' - defaultMessage='Switch' - /> - </button> - </Modal.Footer> - </Modal> - ); - } -} - -SwitchChannelModal.propTypes = { - show: PropTypes.bool.isRequired, - onHide: PropTypes.func.isRequired -}; - diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx index d06b2dd57..948649b45 100644 --- a/webapp/components/navbar.jsx +++ b/webapp/components/navbar.jsx @@ -21,8 +21,9 @@ import ChannelStore from 'stores/channel_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import SearchStore from 'stores/search_store.jsx'; +import ModalStore from 'stores/modal_store.jsx'; -import ChannelSwitchModal from './channel_switch_modal.jsx'; +import QuickSwitchModal from 'components/quick_switch_modal'; import * as Utils from 'utils/utils.jsx'; import * as ChannelUtils from 'utils/channel_utils.jsx'; @@ -44,6 +45,8 @@ import {Link} from 'react-router/es6'; import PropTypes from 'prop-types'; import React from 'react'; +import store from 'stores/redux_store.jsx'; +import {getMyTeams} from 'mattermost-redux/selectors/entities/teams'; export default class Navbar extends React.Component { constructor(props) { @@ -64,8 +67,9 @@ export default class Navbar extends React.Component { this.showMembersModal = this.showMembersModal.bind(this); this.hideMembersModal = this.hideMembersModal.bind(this); - this.showChannelSwitchModal = this.showChannelSwitchModal.bind(this); - this.hideChannelSwitchModal = this.hideChannelSwitchModal.bind(this); + this.toggleQuickSwitchModal = this.toggleQuickSwitchModal.bind(this); + this.hideQuickSwitchModal = this.hideQuickSwitchModal.bind(this); + this.handleQuickSwitchKeyPress = this.handleQuickSwitchKeyPress.bind(this); this.openDirectMessageModal = this.openDirectMessageModal.bind(this); this.getPinnedPosts = this.getPinnedPosts.bind(this); @@ -78,7 +82,8 @@ export default class Navbar extends React.Component { state.showEditChannelHeaderModal = false; state.showMembersModal = false; state.showRenameChannelModal = false; - state.showChannelSwitchModal = false; + state.showQuickSwitchModal = false; + state.quickSwitchMode = 'channel'; this.state = state; } @@ -106,8 +111,9 @@ export default class Navbar extends React.Component { UserStore.addStatusesChangeListener(this.onChange); UserStore.addChangeListener(this.onChange); PreferenceStore.addChangeListener(this.onChange); + ModalStore.addModalListener(ActionTypes.TOGGLE_QUICK_SWITCH_MODAL, this.toggleQuickSwitchModal); $('.inner-wrap').click(this.hideSidebars); - document.addEventListener('keydown', this.showChannelSwitchModal); + document.addEventListener('keydown', this.handleQuickSwitchKeyPress); } componentWillUnmount() { @@ -116,7 +122,8 @@ export default class Navbar extends React.Component { UserStore.removeStatusesChangeListener(this.onChange); UserStore.removeChangeListener(this.onChange); PreferenceStore.removeChangeListener(this.onChange); - document.removeEventListener('keydown', this.showChannelSwitchModal); + ModalStore.removeModalListener(ActionTypes.TOGGLE_QUICK_SWITCH_MODAL, this.toggleQuickSwitchModal); + document.removeEventListener('keydown', this.handleQuickSwitchKeyPress); } handleSubmit(e) { @@ -212,16 +219,32 @@ export default class Navbar extends React.Component { this.setState({showMembersModal: false}); } - showChannelSwitchModal(e) { - if (Utils.cmdOrCtrlPressed(e) && e.keyCode === Constants.KeyCodes.K) { + handleQuickSwitchKeyPress(e) { + if (Utils.cmdOrCtrlPressed(e, true) && e.keyCode === Constants.KeyCodes.K) { e.preventDefault(); - this.setState({showChannelSwitchModal: !this.state.showChannelSwitchModal}); + if (e.altKey) { + if (getMyTeams(store.getState()).length <= 1) { + return; + } + this.toggleQuickSwitchModal('team'); + } else { + this.toggleQuickSwitchModal('channel'); + } + } + } + + toggleQuickSwitchModal(mode = 'channel') { + if (this.state.showQuickSwitchModal) { + this.setState({showQuickSwitchModal: false, quickSwitchMode: 'channel'}); + } else { + this.setState({showQuickSwitchModal: true, quickSwitchMode: mode}); } } - hideChannelSwitchModal() { + hideQuickSwitchModal() { this.setState({ - showChannelSwitchModal: false + showQuickSwitchModal: false, + quickSwitchMode: 'channel' }); } @@ -770,7 +793,7 @@ export default class Navbar extends React.Component { var editChannelPurposeModal = null; let renameChannelModal = null; let channelMembersModal = null; - let channelSwitchModal = null; + let quickSwitchModal = null; if (channel) { popoverContent = ( @@ -883,10 +906,11 @@ export default class Navbar extends React.Component { ); } - channelSwitchModal = ( - <ChannelSwitchModal - show={this.state.showChannelSwitchModal} - onHide={this.hideChannelSwitchModal} + quickSwitchModal = ( + <QuickSwitchModal + show={this.state.showQuickSwitchModal} + onHide={this.hideQuickSwitchModal} + initialMode={this.state.quickSwitchMode} /> ); } @@ -926,7 +950,7 @@ export default class Navbar extends React.Component { {leaveChannelModal} {renameChannelModal} {channelMembersModal} - {channelSwitchModal} + {quickSwitchModal} </div> ); } diff --git a/webapp/components/quick_switch_modal/index.js b/webapp/components/quick_switch_modal/index.js new file mode 100644 index 000000000..7826fd8f5 --- /dev/null +++ b/webapp/components/quick_switch_modal/index.js @@ -0,0 +1,16 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {getMyTeams} from 'mattermost-redux/selectors/entities/teams'; + +import QuickSwitchModal from './quick_switch_modal.jsx'; + +function mapStateToProps(state, ownProps) { + return { + ...ownProps, + showTeamSwitcher: getMyTeams(state).length > 1 + }; +} + +export default connect(mapStateToProps)(QuickSwitchModal); diff --git a/webapp/components/quick_switch_modal/quick_switch_modal.jsx b/webapp/components/quick_switch_modal/quick_switch_modal.jsx new file mode 100644 index 000000000..c3095caf9 --- /dev/null +++ b/webapp/components/quick_switch_modal/quick_switch_modal.jsx @@ -0,0 +1,322 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SuggestionList from 'components/suggestion/suggestion_list.jsx'; +import SuggestionBox from 'components/suggestion/suggestion_box.jsx'; +import SwitchChannelProvider from 'components/suggestion/switch_channel_provider.jsx'; +import SwitchTeamProvider from 'components/suggestion/switch_team_provider.jsx'; + +import {goToChannel, openDirectChannelToUser} from 'actions/channel_actions.jsx'; + +import Constants from 'utils/constants.jsx'; +import * as Utils from 'utils/utils.jsx'; + +import React from 'react'; +import PropTypes from 'prop-types'; +import {browserHistory} from 'react-router/es6'; +import {Modal} from 'react-bootstrap'; +import {FormattedMessage} from 'react-intl'; + +// Redux actions +import store from 'stores/redux_store.jsx'; +const getState = store.getState; + +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getUserByUsername} from 'mattermost-redux/selectors/entities/users'; + +const CHANNEL_MODE = 'channel'; +const TEAM_MODE = 'team'; + +export default class QuickSwitchModal extends React.PureComponent { + static propTypes = { + + /** + * The mode to start in when showing the modal, either 'channel' or 'team' + */ + initialMode: PropTypes.string.isRequired, + + /** + * Set to show the modal + */ + show: PropTypes.bool.isRequired, + + /** + * The function called to hide the modal + */ + onHide: PropTypes.func.isRequired, + + /** + * Set to show team switcher + */ + showTeamSwitcher: PropTypes.bool + } + + static defaultProps = { + initialMode: CHANNEL_MODE + } + + constructor(props) { + super(props); + + this.onChange = this.onChange.bind(this); + this.onShow = this.onShow.bind(this); + this.onHide = this.onHide.bind(this); + this.onExited = this.onExited.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.switchToChannel = this.switchToChannel.bind(this); + this.switchMode = this.switchMode.bind(this); + this.focusTextbox = this.focusTextbox.bind(this); + + this.enableChannelProvider = this.enableChannelProvider.bind(this); + this.enableTeamProvider = this.enableTeamProvider.bind(this); + this.channelProviders = [new SwitchChannelProvider()]; + this.teamProviders = [new SwitchTeamProvider()]; + + this.state = { + text: '', + mode: props.initialMode + }; + } + + componentDidUpdate(prevProps) { + if (this.props.show && !prevProps.show) { + this.focusTextbox(); + } + } + + componentWillReceiveProps(nextProps) { + if (!this.props.show && nextProps.show) { + this.setState({mode: nextProps.initialMode, text: ''}); + } + } + + focusTextbox() { + if (this.refs.switchbox == null) { + return; + } + + const textbox = this.refs.switchbox.getTextbox(); + textbox.focus(); + Utils.placeCaretAtEnd(textbox); + } + + onShow() { + this.setState({ + text: '' + }); + } + + onHide() { + this.setState({ + text: '' + }); + this.props.onHide(); + } + + onExited() { + setTimeout(() => { + document.querySelector('#post_textbox').focus(); + }); + } + + onChange(e) { + this.setState({text: e.target.value}); + } + + handleKeyDown(e) { + if (e.keyCode === Constants.KeyCodes.TAB) { + e.preventDefault(); + this.switchMode(); + } + } + + handleSubmit(selected) { + let channel = null; + + if (!selected) { + return; + } + + if (this.state.mode === CHANNEL_MODE) { + const selectedChannel = selected.channel; + if (selectedChannel.type === Constants.DM_CHANNEL) { + const user = getUserByUsername(getState(), selectedChannel.name); + + if (user) { + openDirectChannelToUser( + user.id, + (ch) => { + channel = ch; + this.switchToChannel(channel); + }, + () => { + channel = null; + this.switchToChannel(channel); + } + ); + } + } else { + channel = getChannel(getState(), selectedChannel.id); + this.switchToChannel(channel); + } + } else { + browserHistory.push('/' + selected.name); + this.onHide(); + } + } + + switchToChannel(channel) { + if (channel != null) { + goToChannel(channel); + this.onHide(); + } + } + + enableChannelProvider() { + this.channelProviders[0].disableDispatches = false; + this.teamProviders[0].disableDispatches = true; + } + + enableTeamProvider() { + this.teamProviders[0].disableDispatches = false; + this.channelProviders[0].disableDispatches = true; + } + + switchMode() { + if (this.state.mode === CHANNEL_MODE && this.props.showTeamSwitcher) { + this.enableTeamProvider(); + this.setState({mode: TEAM_MODE}); + } else if (this.state.mode === TEAM_MODE) { + this.enableChannelProvider(); + this.setState({mode: CHANNEL_MODE}); + } + } + + render() { + let providers = this.channelProviders; + let header; + let renderDividers = true; + + let channelShortcut = 'quick_switch_modal.channelsShortcut.windows'; + if (Utils.isMac()) { + channelShortcut = 'quick_switch_modal.channelsShortcut.mac'; + } + + let teamShortcut = 'quick_switch_modal.teamsShortcut.windows'; + if (Utils.isMac()) { + teamShortcut = 'quick_switch_modal.teamsShortcut.mac'; + } + + if (this.props.showTeamSwitcher) { + let channelsActiveClass = ''; + let teamsActiveClass = ''; + if (this.state.mode === TEAM_MODE) { + providers = this.teamProviders; + renderDividers = false; + teamsActiveClass = 'active'; + } else { + channelsActiveClass = 'active'; + } + + header = ( + <div className='nav nav-tabs'> + <li className={channelsActiveClass}> + <a + href='#' + onClick={(e) => { + e.preventDefault(); + this.enableChannelProvider(); + this.setState({mode: 'channel'}); + this.focusTextbox(); + }} + > + <FormattedMessage + id='quick_switch_modal.channels' + defaultMessage='Channels' + /> + <span className='small'> + <FormattedMessage + id={channelShortcut} + defaultMessage='CTRL+K' + /> + </span> + </a> + </li> + <li className={teamsActiveClass}> + <a + href='#' + onClick={(e) => { + e.preventDefault(); + this.enableTeamProvider(); + this.setState({mode: 'team'}); + this.focusTextbox(); + }} + > + <FormattedMessage + id='quick_switch_modal.teams' + defaultMessage='Teams' + /> + <span className='small'> + <FormattedMessage + id={teamShortcut} + defaultMessage='CTRL+ALT+K' + /> + </span> + </a> + </li> + </div> + ); + } + + let help; + if (this.props.showTeamSwitcher) { + help = ( + <FormattedMessage + id='quick_switch_modal.help' + defaultMessage='Use TAB to toggle between teams/channels, ↑↓ to browse, ↵ to confirm, ESC to dismiss' + /> + ); + } else { + help = ( + <FormattedMessage + id='quick_switch_modal.help_no_team' + defaultMessage='Type a channel name. Use ↑↓ to browse, ↵ to confirm, ESC to dismiss' + /> + ); + } + + return ( + <Modal + dialogClassName='channel-switch-modal modal--overflow' + ref='modal' + show={this.props.show} + onHide={this.onHide} + onExited={this.onExited} + > + <Modal.Header closeButton={true}/> + <Modal.Body> + {header} + <div className='modal__hint'> + {help} + </div> + <SuggestionBox + ref='switchbox' + className='form-control focused' + type='input' + onChange={this.onChange} + value={this.state.text} + onKeyDown={this.handleKeyDown} + onItemSelected={this.handleSubmit} + listComponent={SuggestionList} + maxLength='64' + providers={providers} + listStyle='bottom' + completeOnTab={false} + renderDividers={renderDividers} + /> + </Modal.Body> + </Modal> + ); + } +} diff --git a/webapp/components/sidebar.jsx b/webapp/components/sidebar.jsx index f8b3616f3..a802c2f4f 100644 --- a/webapp/components/sidebar.jsx +++ b/webapp/components/sidebar.jsx @@ -17,6 +17,7 @@ import TeamStore from 'stores/team_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import ModalStore from 'stores/modal_store.jsx'; +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import {sortTeamsByDisplayName} from 'utils/team_utils.jsx'; import * as Utils from 'utils/utils.jsx'; @@ -424,6 +425,13 @@ export default class Sidebar extends React.Component { } } + openQuickSwitcher(e) { + e.preventDefault(); + AppDispatcher.handleViewAction({ + type: ActionTypes.TOGGLE_QUICK_SWITCH_MODAL + }); + } + createTutorialTip() { const screens = []; @@ -790,6 +798,11 @@ export default class Sidebar extends React.Component { ); } + let quickSwitchText = 'sidebar.switch_channels'; + if (Utils.isMac()) { + quickSwitchText += '.mac'; + } + return ( <div className='sidebar--left' @@ -890,6 +903,18 @@ export default class Sidebar extends React.Component { {directMessageMore} </ul> </div> + <div style={{height: '20px', width: '100%'}}> + <a + href='#' + className='sidebar__switcher' + onClick={this.openQuickSwitcher} + > + <FormattedMessage + id={quickSwitchText} + defaultMessage='Switch Channels (CTRL + K)' + /> + </a> + </div> </div> ); } diff --git a/webapp/components/suggestion/provider.jsx b/webapp/components/suggestion/provider.jsx index 39bb135a8..a5b54fb26 100644 --- a/webapp/components/suggestion/provider.jsx +++ b/webapp/components/suggestion/provider.jsx @@ -7,6 +7,7 @@ export default class Provider { constructor() { this.latestPrefix = ''; this.latestComplete = true; + this.disableDispatches = false; } handlePretextChanged(suggestionId, pretext) { // eslint-disable-line no-unused-vars @@ -22,6 +23,10 @@ export default class Provider { } shouldCancelDispatch(prefix) { + if (this.disableDispatches) { + return true; + } + if (prefix === this.latestPrefix) { this.latestComplete = true; } else if (this.latestComplete) { diff --git a/webapp/components/suggestion/suggestion_box.jsx b/webapp/components/suggestion/suggestion_box.jsx index 1915b22b7..e1de927b9 100644 --- a/webapp/components/suggestion/suggestion_box.jsx +++ b/webapp/components/suggestion/suggestion_box.jsx @@ -15,6 +15,71 @@ import PropTypes from 'prop-types'; import React from 'react'; export default class SuggestionBox extends React.Component { + static propTypes = { + + /** + * The list component to render, usually SuggestionList + */ + listComponent: PropTypes.func.isRequired, + + /** + * The HTML input box type + */ + type: PropTypes.oneOf(['input', 'textarea', 'search']).isRequired, + + /** + * The value of in the input + */ + value: PropTypes.string.isRequired, + + /** + * Array of suggestion providers + */ + providers: PropTypes.arrayOf(PropTypes.object), + + /** + * Where the list will be displayed relative to the input box, defaults to 'top' + */ + listStyle: PropTypes.string, + + /** + * Set to true to draw dividers between types of list items, defaults to false + */ + renderDividers: PropTypes.bool, + + /** + * Set to allow TAB to select an item in the list, defaults to true + */ + completeOnTab: PropTypes.bool, + + /** + * Function called when input box loses focus + */ + onBlur: PropTypes.func, + + /** + * Function called when input box value changes + */ + onChange: PropTypes.func, + + /** + * Function called when a key is pressed and the input box is in focus + */ + onKeyDown: PropTypes.func, + + /** + * Function called when an item is selected + */ + onItemSelected: PropTypes.func + } + + static defaultProps = { + type: 'input', + listStyle: 'top', + renderDividers: false, + completeOnTab: true + } + constructor(props) { super(props); @@ -46,6 +111,14 @@ export default class SuggestionBox extends React.Component { SuggestionStore.unregisterSuggestionBox(this.suggestionId); } + componentDidUpdate(prevProps) { + if (this.props.providers !== prevProps.providers) { + const textbox = this.getTextbox(); + const pretext = textbox.value.substring(0, textbox.selectionEnd); + GlobalActions.emitSuggestionPretextChanged(this.suggestionId, pretext); + } + } + getTextbox() { if (this.props.type === 'textarea') { return this.refs.textbox.getDOMNode(); @@ -171,7 +244,7 @@ export default class SuggestionBox extends React.Component { } else if (e.which === KeyCodes.DOWN) { GlobalActions.emitSelectNextSuggestion(this.suggestionId); e.preventDefault(); - } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.TAB) { + } else if (e.which === KeyCodes.ENTER || (this.props.completeOnTab && e.which === KeyCodes.TAB)) { this.handleCompleteWord(SuggestionStore.getSelection(this.suggestionId), SuggestionStore.getSelectedMatchedPretext(this.suggestionId)); this.props.onKeyDown(e); e.preventDefault(); @@ -281,23 +354,3 @@ export default class SuggestionBox extends React.Component { return ''; } } - -SuggestionBox.defaultProps = { - type: 'input', - listStyle: 'top' -}; - -SuggestionBox.propTypes = { - listComponent: PropTypes.func.isRequired, - type: PropTypes.oneOf(['input', 'textarea', 'search']).isRequired, - value: PropTypes.string.isRequired, - providers: PropTypes.arrayOf(PropTypes.object), - listStyle: PropTypes.string, - renderDividers: PropTypes.bool, - - // explicitly name any input event handlers we override and need to manually call - onBlur: PropTypes.func, - onChange: PropTypes.func, - onKeyDown: PropTypes.func, - onItemSelected: PropTypes.func -}; diff --git a/webapp/components/suggestion/suggestion_list.jsx b/webapp/components/suggestion/suggestion_list.jsx index 59f0d02f8..64e8713c5 100644 --- a/webapp/components/suggestion/suggestion_list.jsx +++ b/webapp/components/suggestion/suggestion_list.jsx @@ -1,14 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import SuggestionStore from 'stores/suggestion_store.jsx'; + import $ from 'jquery'; -import PropTypes from 'prop-types'; import React from 'react'; import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; import {FormattedMessage} from 'react-intl'; -import SuggestionStore from 'stores/suggestion_store.jsx'; - export default class SuggestionList extends React.Component { static propTypes = { suggestionId: PropTypes.string.isRequired, @@ -111,6 +111,17 @@ export default class SuggestionList extends React.Component { ); } + renderLoading(type) { + return ( + <div + key={type + '-loading'} + className='suggestion-loader' + > + <i className='fa fa-spinner fa-pulse fa-fw margin-bottom'/> + </div> + ); + } + render() { if (this.state.items.length === 0) { return null; @@ -131,6 +142,11 @@ export default class SuggestionList extends React.Component { lastType = item.type; } + if (item.loading) { + items.push(this.renderLoading(item.type)); + continue; + } + items.push( <Component key={term} diff --git a/webapp/components/suggestion/switch_channel_provider.jsx b/webapp/components/suggestion/switch_channel_provider.jsx index 89af74c6d..9790de38e 100644 --- a/webapp/components/suggestion/switch_channel_provider.jsx +++ b/webapp/components/suggestion/switch_channel_provider.jsx @@ -4,10 +4,6 @@ import Suggestion from './suggestion.jsx'; import Provider from './provider.jsx'; -import ChannelStore from 'stores/channel_store.jsx'; -import UserStore from 'stores/user_store.jsx'; - -import {autocompleteUsers} from 'actions/user_actions.jsx'; import Client from 'client/web_client.jsx'; import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import {Constants, ActionTypes} from 'utils/constants.jsx'; @@ -16,30 +12,44 @@ import {sortChannelsByDisplayName, getChannelDisplayName} from 'utils/channel_ut import React from 'react'; +import store from 'stores/redux_store.jsx'; +const getState = store.getState; +const dispatch = store.dispatch; + +import {searchChannels} from 'mattermost-redux/actions/channels'; +import {autocompleteUsers} from 'mattermost-redux/actions/users'; + +import {getCurrentUserId, searchProfiles} from 'mattermost-redux/selectors/entities/users'; +import {getChannelsInCurrentTeam, getMyChannelMemberships, getGroupChannels} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {getBool} from 'mattermost-redux/selectors/entities/preferences'; +import {Preferences} from 'mattermost-redux/constants'; + class SwitchChannelSuggestion extends Suggestion { render() { const {item, isSelection} = this.props; + const channel = item.channel; let className = 'mentions__name'; if (isSelection) { className += ' suggestion--selected'; } - let displayName = item.display_name; + let displayName = channel.display_name; let icon = null; - if (item.type === Constants.OPEN_CHANNEL) { + if (channel.type === Constants.OPEN_CHANNEL) { icon = <div className='status'><i className='fa fa-globe'/></div>; - } else if (item.type === Constants.PRIVATE_CHANNEL) { + } else if (channel.type === Constants.PRIVATE_CHANNEL) { icon = <div className='status'><i className='fa fa-lock'/></div>; - } else if (item.type === Constants.GM_CHANNEL) { - displayName = getChannelDisplayName(item); + } else if (channel.type === Constants.GM_CHANNEL) { + displayName = getChannelDisplayName(channel); icon = <div className='status status--group'>{'G'}</div>; } else { icon = ( <div className='pull-left'> <img className='mention__image' - src={Client.getUsersRoute() + '/' + item.id + '/image?time=' + item.last_picture_update} + src={Client.getUsersRoute() + '/' + channel.id + '/image?time=' + channel.last_picture_update} /> </div> ); @@ -57,83 +67,179 @@ class SwitchChannelSuggestion extends Suggestion { } } +let prefix = ''; + +function quickSwitchSorter(wrappedA, wrappedB) { + if (wrappedA.type === Constants.MENTION_CHANNELS && wrappedB.type === Constants.MENTION_MORE_CHANNELS) { + return -1; + } else if (wrappedB.type === Constants.MENTION_CHANNELS && wrappedA.type === Constants.MENTION_MORE_CHANNELS) { + return 1; + } + + const a = wrappedA.channel; + const b = wrappedB.channel; + + let aDisplayName = getChannelDisplayName(a).toLowerCase(); + let bDisplayName = getChannelDisplayName(b).toLowerCase(); + + if (a.type === Constants.DM_CHANNEL) { + aDisplayName = aDisplayName.substring(1); + } + + if (b.type === Constants.DM_CHANNEL) { + bDisplayName = bDisplayName.substring(1); + } + + const aStartsWith = aDisplayName.startsWith(prefix); + const bStartsWith = bDisplayName.startsWith(prefix); + if (aStartsWith && bStartsWith) { + return sortChannelsByDisplayName(a, b); + } else if (!aStartsWith && !bStartsWith) { + return sortChannelsByDisplayName(a, b); + } else if (aStartsWith) { + return -1; + } + + return 1; +} + export default class SwitchChannelProvider extends Provider { handlePretextChanged(suggestionId, channelPrefix) { if (channelPrefix) { + prefix = channelPrefix; this.startNewRequest(suggestionId, channelPrefix); - const allChannels = ChannelStore.getAll(); - const channels = []; + // Dispatch suggestions for local data + const channels = getChannelsInCurrentTeam(getState()).concat(getGroupChannels(getState())); + const users = Object.assign([], searchProfiles(getState(), channelPrefix, true), true); + this.formatChannelsAndDispatch(channelPrefix, suggestionId, channels, users, true); - autocompleteUsers( - channelPrefix, - (data) => { - const users = Object.assign([], data.users); + // Fetch data from the server and dispatch + this.fetchUsersAndChannels(channelPrefix, suggestionId); - if (this.shouldCancelDispatch(channelPrefix)) { - return; - } + return true; + } - const currentId = UserStore.getCurrentId(); + return false; + } - for (const id of Object.keys(allChannels)) { - const channel = allChannels[id]; - if (channel.display_name.toLowerCase().indexOf(channelPrefix.toLowerCase()) !== -1) { - const newChannel = Object.assign({}, channel); - if (newChannel.type === Constants.GM_CHANNEL) { - newChannel.name = getChannelDisplayName(newChannel); - } - channels.push(newChannel); - } - } + async fetchUsersAndChannels(channelPrefix, suggestionId) { + const usersAsync = autocompleteUsers(channelPrefix)(dispatch, getState); + const channelsAsync = searchChannels(getCurrentTeamId(getState()), channelPrefix)(dispatch, getState); + await usersAsync; + await channelsAsync; - const userMap = {}; - for (let i = 0; i < users.length; i++) { - const user = users[i]; - let displayName = `@${user.username} `; + if (this.shouldCancelDispatch(channelPrefix)) { + return; + } - if (user.id === currentId) { - continue; - } + const users = Object.assign([], searchProfiles(getState(), channelPrefix, true)); + const channels = getChannelsInCurrentTeam(getState()).concat(getGroupChannels(getState())); + this.formatChannelsAndDispatch(channelPrefix, suggestionId, channels, users); + } - if ((user.first_name || user.last_name) && user.nickname) { - displayName += `- ${Utils.getFullName(user)} (${user.nickname})`; - } else if (user.nickname) { - displayName += `- (${user.nickname})`; - } else if (user.first_name || user.last_name) { - displayName += `- ${Utils.getFullName(user)}`; - } + formatChannelsAndDispatch(channelPrefix, suggestionId, allChannels, users, skipNotInChannel = false) { + const channels = []; + const members = getMyChannelMemberships(getState()); + + if (this.shouldCancelDispatch(channelPrefix)) { + return; + } - const newChannel = { - display_name: displayName, - name: user.username, - id: user.id, - update_at: user.update_at, - type: Constants.DM_CHANNEL - }; - channels.push(newChannel); - userMap[user.id] = user; + const currentId = getCurrentUserId(getState()); + + for (const id of Object.keys(allChannels)) { + const channel = allChannels[id]; + const member = members[channel.id]; + + if (channel.display_name.toLowerCase().indexOf(channelPrefix.toLowerCase()) !== -1) { + const newChannel = Object.assign({}, channel); + const wrappedChannel = {channel: newChannel, name: newChannel.name}; + if (newChannel.type === Constants.GM_CHANNEL) { + newChannel.name = getChannelDisplayName(newChannel); + wrappedChannel.name = newChannel.name; + const isGMVisible = getBool(getState(), Preferences.CATEGORY_GROUP_CHANNEL_SHOW, newChannel.id, false); + if (isGMVisible) { + wrappedChannel.type = Constants.MENTION_CHANNELS; + } else { + wrappedChannel.type = Constants.MENTION_MORE_CHANNELS; + if (skipNotInChannel) { + continue; + } } + } else if (member) { + wrappedChannel.type = Constants.MENTION_CHANNELS; + } else { + wrappedChannel.type = Constants.MENTION_MORE_CHANNELS; + if (skipNotInChannel || !newChannel.display_name.startsWith(channelPrefix)) { + continue; + } + } - const channelNames = channels. - sort(sortChannelsByDisplayName). - map((channel) => channel.name); - - AppDispatcher.handleServerAction({ - type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS, - id: suggestionId, - matchedPretext: channelPrefix, - terms: channelNames, - items: channels, - component: SwitchChannelSuggestion - }); - - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_PROFILES, - profiles: userMap - }); + channels.push(wrappedChannel); + } + } + + for (let i = 0; i < users.length; i++) { + const user = users[i]; + const isDMVisible = getBool(getState(), Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, false); + let displayName = `@${user.username} `; + + if (user.id === currentId) { + continue; + } + + if ((user.first_name || user.last_name) && user.nickname) { + displayName += `- ${Utils.getFullName(user)} (${user.nickname})`; + } else if (user.nickname) { + displayName += `- (${user.nickname})`; + } else if (user.first_name || user.last_name) { + displayName += `- ${Utils.getFullName(user)}`; + } + + const wrappedChannel = { + channel: { + display_name: displayName, + name: user.username, + id: user.id, + update_at: user.update_at, + type: Constants.DM_CHANNEL + }, + name: user.username + }; + + if (isDMVisible) { + wrappedChannel.type = Constants.MENTION_CHANNELS; + } else { + wrappedChannel.type = Constants.MENTION_MORE_CHANNELS; + if (skipNotInChannel) { + continue; } - ); + } + + channels.push(wrappedChannel); } + + const channelNames = channels. + sort(quickSwitchSorter). + map((wrappedChannel) => wrappedChannel.channel.name); + + if (skipNotInChannel) { + channels.push({ + type: Constants.MENTION_MORE_CHANNELS, + loading: true + }); + } + + setTimeout(() => { + AppDispatcher.handleServerAction({ + type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS, + id: suggestionId, + matchedPretext: channelPrefix, + terms: channelNames, + items: channels, + component: SwitchChannelSuggestion + }); + }, 0); } } diff --git a/webapp/components/suggestion/switch_team_provider.jsx b/webapp/components/suggestion/switch_team_provider.jsx new file mode 100644 index 000000000..ff2a8f24b --- /dev/null +++ b/webapp/components/suggestion/switch_team_provider.jsx @@ -0,0 +1,96 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Suggestion from './suggestion.jsx'; +import Provider from './provider.jsx'; + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; +import {ActionTypes} from 'utils/constants.jsx'; +import LocalizationStore from 'stores/localization_store.jsx'; + +import React from 'react'; + +// Redux actions +import store from 'stores/redux_store.jsx'; +const getState = store.getState; + +import * as Selectors from 'mattermost-redux/selectors/entities/teams'; + +class SwitchTeamSuggestion extends Suggestion { + render() { + const {item, isSelection} = this.props; + + let className = 'mentions__name'; + if (isSelection) { + className += ' suggestion--selected'; + } + + return ( + <div + onClick={this.handleClick} + className={className} + > + <div className='status'><i className='fa fa-group'/></div> + {item.display_name} + </div> + ); + } +} + +let prefix = ''; + +function quickSwitchSorter(a, b) { + const aDisplayName = a.display_name.toLowerCase(); + const bDisplayName = b.display_name.toLowerCase(); + const aStartsWith = aDisplayName.startsWith(prefix); + const bStartsWith = bDisplayName.startsWith(prefix); + + if (aStartsWith && bStartsWith) { + const locale = LocalizationStore.getLocale(); + + if (aDisplayName !== bDisplayName) { + return aDisplayName.localeCompare(bDisplayName, locale, {numeric: true}); + } + + return a.name.localeCompare(b.name, locale, {numeric: true}); + } else if (aStartsWith) { + return -1; + } + + return 1; +} + +export default class SwitchTeamProvider extends Provider { + handlePretextChanged(suggestionId, teamPrefix) { + if (teamPrefix) { + prefix = teamPrefix; + this.startNewRequest(suggestionId, teamPrefix); + + const allTeams = Selectors.getMyTeams(getState()); + + const teams = allTeams.filter((team) => { + return team.display_name.toLowerCase().indexOf(teamPrefix) !== -1 || + team.name.indexOf(teamPrefix) !== -1; + }); + + const teamNames = teams. + sort(quickSwitchSorter). + map((team) => team.name); + + setTimeout(() => { + AppDispatcher.handleServerAction({ + type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS, + id: suggestionId, + matchedPretext: teamPrefix, + terms: teamNames, + items: teams, + component: SwitchTeamSuggestion + }); + }, 0); + + return true; + } + + return false; + } +} |