diff options
author | Joram Wilander <jwawilander@gmail.com> | 2017-05-31 16:51:42 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-05-31 16:51:42 -0400 |
commit | 5aaedb9663b987caf1fb11ea6062bcc44e6bafca (patch) | |
tree | bd77c10168f9fb1b0f998b08a3b2a3761512a451 /webapp/components/suggestion | |
parent | 8ce72aedc3a5b4f783fb6ebab38aac8bf5f413ae (diff) | |
download | chat-5aaedb9663b987caf1fb11ea6062bcc44e6bafca.tar.gz chat-5aaedb9663b987caf1fb11ea6062bcc44e6bafca.tar.bz2 chat-5aaedb9663b987caf1fb11ea6062bcc44e6bafca.zip |
PLT-5699 Improvements to channel switcher (#6486)
* Refactor channel switcher to not wait on server results
* Change channel switcher to quick switcher and include team switching
* Add sections, update ordering and add discoverability button
* Fix styling error
* Use CMD in text if on mac
* Clean yarn cache on every install
* Various UX updates per feedback
* Add shortcut help text for team switcher
* Couple more updates per feedback
* Some minor fixes for GM and autocomplete race
* Updating UI for channel switcher (#6504)
* Updating channel switcher button (#6506)
* Updating switcher modal on mobile (#6507)
* Removed jQuery usage
* Rename function to toggleQuickSwitchModal
Diffstat (limited to 'webapp/components/suggestion')
-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 |
5 files changed, 371 insertions, 95 deletions
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; + } +} |