diff options
Diffstat (limited to 'web/react/components/suggestion')
8 files changed, 742 insertions, 0 deletions
diff --git a/web/react/components/suggestion/at_mention_provider.jsx b/web/react/components/suggestion/at_mention_provider.jsx new file mode 100644 index 000000000..8c2893448 --- /dev/null +++ b/web/react/components/suggestion/at_mention_provider.jsx @@ -0,0 +1,100 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SuggestionStore from '../../stores/suggestion_store.jsx'; +import UserStore from '../../stores/user_store.jsx'; +import * as Utils from '../../utils/utils.jsx'; + +class AtMentionSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let username; + let description; + let icon; + if (item.username === 'all') { + username = 'all'; + description = 'Notifies everyone in the team'; + icon = <i className='mention-img fa fa-users fa-2x' />; + } else if (item.username === 'channel') { + username = 'channel'; + description = 'Notifies everyone in the channel'; + icon = <i className='mention-img fa fa-users fa-2x' />; + } else { + username = item.username; + description = Utils.getFullName(item); + icon = ( + <img + className='mention-img' + src={'/api/v1/users/' + item.id + '/image?time=' + item.update_at + '&' + Utils.getSessionIndex()} + /> + ); + } + + let className = 'mentions-name'; + if (isSelection) { + className += ' suggestion--selected'; + } + + return ( + <div + className={className} + onClick={onClick} + > + <div className='pull-left'> + {icon} + </div> + <div className='pull-left mention-align'> + <span> + {'@' + username} + </span> + <span className='mention-fullname'> + {description} + </span> + </div> + </div> + ); + } +} + +AtMentionSuggestion.propTypes = { + item: React.PropTypes.object.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class AtMentionProvider { + handlePretextChanged(suggestionId, pretext) { + const captured = (/@([a-z0-9\-\._]*)$/i).exec(pretext); + if (captured) { + const usernamePrefix = captured[1]; + + const users = UserStore.getProfiles(); + let filtered = []; + + for (const id of Object.keys(users)) { + const user = users[id]; + + if (user.username.startsWith(usernamePrefix)) { + filtered.push(user); + } + } + + // add dummy users to represent the @all and @channel special mentions + if ('all'.startsWith(usernamePrefix)) { + filtered.push({username: 'all'}); + } + + if ('channel'.startsWith(usernamePrefix)) { + filtered.push({username: 'channel'}); + } + + filtered = filtered.sort((a, b) => a.username.localeCompare(b.username)); + + const mentions = filtered.map((user) => '@' + user.username); + + SuggestionStore.setMatchedPretext(suggestionId, captured[0]); + SuggestionStore.addSuggestions(suggestionId, mentions, filtered, AtMentionSuggestion); + } + } +} diff --git a/web/react/components/suggestion/command_provider.jsx b/web/react/components/suggestion/command_provider.jsx new file mode 100644 index 000000000..91d556bb9 --- /dev/null +++ b/web/react/components/suggestion/command_provider.jsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as AsyncClient from '../../utils/async_client.jsx'; +import SuggestionStore from '../../stores/suggestion_store.jsx'; + +class CommandSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let className = 'command-name'; + if (isSelection) { + className += ' suggestion--selected'; + } + + return ( + <div + className={className} + onClick={onClick} + > + <div className='command__title'> + <string>{item.suggestion}</string> + </div> + <div className='command__desc'> + {item.description} + </div> + </div> + ); + } +} + +CommandSuggestion.propTypes = { + item: React.PropTypes.object.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class CommandProvider { + handlePretextChanged(suggestionId, pretext) { + if (pretext.startsWith('/')) { + SuggestionStore.setMatchedPretext(suggestionId, pretext); + + AsyncClient.getSuggestedCommands(pretext, suggestionId, CommandSuggestion); + } + } +} diff --git a/web/react/components/suggestion/emoticon_provider.jsx b/web/react/components/suggestion/emoticon_provider.jsx new file mode 100644 index 000000000..fd470cf21 --- /dev/null +++ b/web/react/components/suggestion/emoticon_provider.jsx @@ -0,0 +1,91 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SuggestionStore from '../../stores/suggestion_store.jsx'; +import * as Emoticons from '../../utils/emoticons.jsx'; + +const MAX_EMOTICON_SUGGESTIONS = 40; + +class EmoticonSuggestion extends React.Component { + render() { + const text = this.props.term; + const name = this.props.item; + + let className = 'emoticon-suggestion'; + if (this.props.isSelection) { + className += ' suggestion--selected'; + } + + return ( + <div + className={className} + onClick={this.props.onClick} + > + <div className='pull-left'> + <img + alt={text} + className='emoticon-suggestion__image' + src={Emoticons.getImagePathForEmoticon(name)} + title={text} + /> + </div> + <div className='pull-left'> + {text} + </div> + </div> + ); + } +} + +EmoticonSuggestion.propTypes = { + item: React.PropTypes.string.isRequired, + term: React.PropTypes.string.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class EmoticonProvider { + handlePretextChanged(suggestionId, pretext) { + const captured = (/(?:^|\s)(:([a-zA-Z0-9_+\-]*))$/g).exec(pretext); + if (captured) { + const text = captured[1]; + const partialName = captured[2]; + + const names = []; + + for (const emoticon of Emoticons.emoticonMap.keys()) { + if (emoticon.indexOf(partialName) !== -1) { + names.push(emoticon); + + if (names.length >= MAX_EMOTICON_SUGGESTIONS) { + break; + } + } + } + + // sort the emoticons so that emoticons starting with the entered text come first + names.sort((a, b) => { + const aPrefix = a.startsWith(partialName); + const bPrefix = b.startsWith(partialName); + + if (aPrefix === bPrefix) { + return a.localeCompare(b); + } else if (aPrefix) { + return -1; + } + + return 1; + }); + + const terms = names.map((name) => ':' + name + ':'); + + if (terms.length > 0) { + SuggestionStore.setMatchedPretext(suggestionId, text); + SuggestionStore.addSuggestions(suggestionId, terms, names, EmoticonSuggestion); + + // force the selection to be cleared since the order of elements may have changed + SuggestionStore.clearSelection(suggestionId); + } + } + } +} diff --git a/web/react/components/suggestion/search_channel_provider.jsx b/web/react/components/suggestion/search_channel_provider.jsx new file mode 100644 index 000000000..7547a9341 --- /dev/null +++ b/web/react/components/suggestion/search_channel_provider.jsx @@ -0,0 +1,69 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import ChannelStore from '../../stores/channel_store.jsx'; +import Constants from '../../utils/constants.jsx'; +import SuggestionStore from '../../stores/suggestion_store.jsx'; + +class SearchChannelSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let className = 'search-autocomplete__item'; + if (isSelection) { + className += ' selected'; + } + + return ( + <div + onClick={onClick} + className={className} + > + {item.name} + </div> + ); + } +} + +SearchChannelSuggestion.propTypes = { + item: React.PropTypes.object.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class SearchChannelProvider { + handlePretextChanged(suggestionId, pretext) { + const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext); + if (captured) { + const channelPrefix = captured[1]; + + const channels = ChannelStore.getAll(); + const publicChannels = []; + const privateChannels = []; + + for (const id of Object.keys(channels)) { + const channel = channels[id]; + + // don't show direct channels + if (channel.type !== Constants.DM_CHANNEL && channel.name.startsWith(channelPrefix)) { + if (channel.type === Constants.OPEN_CHANNEL) { + publicChannels.push(channel); + } else { + privateChannels.push(channel); + } + } + } + + publicChannels.sort((a, b) => a.name.localeCompare(b.name)); + const publicChannelNames = publicChannels.map((channel) => channel.name); + + privateChannels.sort((a, b) => a.name.localeCompare(b.name)); + const privateChannelNames = privateChannels.map((channel) => channel.name); + + SuggestionStore.setMatchedPretext(suggestionId, channelPrefix); + + SuggestionStore.addSuggestions(suggestionId, publicChannelNames, publicChannels, SearchChannelSuggestion); + SuggestionStore.addSuggestions(suggestionId, privateChannelNames, privateChannels, SearchChannelSuggestion); + } + } +} diff --git a/web/react/components/suggestion/search_suggestion_list.jsx b/web/react/components/suggestion/search_suggestion_list.jsx new file mode 100644 index 000000000..3378a33a0 --- /dev/null +++ b/web/react/components/suggestion/search_suggestion_list.jsx @@ -0,0 +1,86 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Constants from '../../utils/constants.jsx'; +import SuggestionList from './suggestion_list.jsx'; +import * as Utils from '../../utils/utils.jsx'; + +export default class SearchSuggestionList extends SuggestionList { + componentDidUpdate(prevProps, prevState) { + if (this.state.items.length > 0 && prevState.items.length === 0) { + this.getContent().perfectScrollbar(); + } + } + + getContent() { + return $(ReactDOM.findDOMNode(this.refs.popover)).find('.popover-content'); + } + + renderChannelDivider(type) { + let text; + if (type === Constants.OPEN_CHANNEL) { + text = 'Public ' + Utils.getChannelTerm(type) + 's'; + } else { + text = 'Private ' + Utils.getChannelTerm(type) + 's'; + } + + return ( + <div + key={type + '-divider'} + className='search-autocomplete__divider' + > + <span>{text}</span> + </div> + ); + } + + render() { + if (this.state.items.length === 0) { + return null; + } + + const items = []; + for (let i = 0; i < this.state.items.length; i++) { + const item = this.state.items[i]; + const term = this.state.terms[i]; + const isSelection = term === this.state.selection; + + // ReactComponent names need to be upper case when used in JSX + const Component = this.state.components[i]; + + // temporary hack to add dividers between public and private channels in the search suggestion list + if (i === 0 || item.type !== this.state.items[i - 1].type) { + if (item.type === Constants.OPEN_CHANNEL) { + items.push(this.renderChannelDivider(Constants.OPEN_CHANNEL)); + } else if (item.type === Constants.PRIVATE_CHANNEL) { + items.push(this.renderChannelDivider(Constants.PRIVATE_CHANNEL)); + } + } + + items.push( + <Component + key={term} + ref={term} + item={item} + isSelection={isSelection} + onClick={this.handleItemClick.bind(this, term)} + /> + ); + } + + return ( + <ReactBootstrap.Popover + ref='popover' + id='search-autocomplete__popover' + className='search-help-popover autocomplete visible' + placement='bottom' + > + {items} + </ReactBootstrap.Popover> + ); + } +} + +SearchSuggestionList.propTypes = { + ...SuggestionList.propTypes +}; diff --git a/web/react/components/suggestion/search_user_provider.jsx b/web/react/components/suggestion/search_user_provider.jsx new file mode 100644 index 000000000..cf2953937 --- /dev/null +++ b/web/react/components/suggestion/search_user_provider.jsx @@ -0,0 +1,62 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SuggestionStore from '../../stores/suggestion_store.jsx'; +import UserStore from '../../stores/user_store.jsx'; + +class SearchUserSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let className = 'search-autocomplete__item'; + if (isSelection) { + className += ' selected'; + } + + return ( + <div + className={className} + onClick={onClick} + > + <img + className='profile-img rounded' + src={'/api/v1/users/' + item.id + '/image?time=' + item.update_at} + /> + {item.username} + </div> + ); + } +} + +SearchUserSuggestion.propTypes = { + item: React.PropTypes.object.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class SearchUserProvider { + handlePretextChanged(suggestionId, pretext) { + const captured = (/\bfrom:\s*(\S*)$/i).exec(pretext); + if (captured) { + const usernamePrefix = captured[1]; + + const users = UserStore.getProfiles(); + let filtered = []; + + for (const id of Object.keys(users)) { + const user = users[id]; + + if (user.username.startsWith(usernamePrefix)) { + filtered.push(user); + } + } + + filtered = filtered.sort((a, b) => a.username.localeCompare(b.username)); + + const usernames = filtered.map((user) => user.username); + + SuggestionStore.setMatchedPretext(suggestionId, usernamePrefix); + SuggestionStore.addSuggestions(suggestionId, usernames, filtered, SearchUserSuggestion); + } + } +} diff --git a/web/react/components/suggestion/suggestion_box.jsx b/web/react/components/suggestion/suggestion_box.jsx new file mode 100644 index 000000000..57a33c24a --- /dev/null +++ b/web/react/components/suggestion/suggestion_box.jsx @@ -0,0 +1,163 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Constants from '../../utils/constants.jsx'; +import * as EventHelpers from '../../dispatcher/event_helpers.jsx'; +import SuggestionStore from '../../stores/suggestion_store.jsx'; +import * as Utils from '../../utils/utils.jsx'; + +const KeyCodes = Constants.KeyCodes; + +export default class SuggestionBox extends React.Component { + constructor(props) { + super(props); + + this.handleDocumentClick = this.handleDocumentClick.bind(this); + + this.handleChange = this.handleChange.bind(this); + this.handleCompleteWord = this.handleCompleteWord.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handlePretextChanged = this.handlePretextChanged.bind(this); + + this.suggestionId = Utils.generateId(); + } + + componentDidMount() { + SuggestionStore.registerSuggestionBox(this.suggestionId); + $(document).on('click', this.handleDocumentClick); + + SuggestionStore.addCompleteWordListener(this.suggestionId, this.handleCompleteWord); + SuggestionStore.addPretextChangedListener(this.suggestionId, this.handlePretextChanged); + } + + componentWillUnmount() { + SuggestionStore.removeCompleteWordListener(this.suggestionId, this.handleCompleteWord); + SuggestionStore.removePretextChangedListener(this.suggestionId, this.handlePretextChanged); + + SuggestionStore.unregisterSuggestionBox(this.suggestionId); + $(document).off('click', this.handleDocumentClick); + } + + getTextbox() { + // this is to support old code that looks at the input/textarea DOM nodes + return ReactDOM.findDOMNode(this.refs.textbox); + } + + handleDocumentClick(e) { + const container = $(ReactDOM.findDOMNode(this)); + if (!(container.is(e.target) || container.has(e.target).length > 0)) { + // we can't just use blur for this because it fires and hides the children before + // their click handlers can be called + EventHelpers.emitClearSuggestions(this.suggestionId); + } + } + + handleChange(e) { + const textbox = ReactDOM.findDOMNode(this.refs.textbox); + const caret = Utils.getCaretPosition(textbox); + const pretext = textbox.value.substring(0, caret); + + EventHelpers.emitSuggestionPretextChanged(this.suggestionId, pretext); + + if (this.props.onUserInput) { + this.props.onUserInput(textbox.value); + } + + if (this.props.onChange) { + this.props.onChange(e); + } + } + + handleCompleteWord(term) { + const textbox = ReactDOM.findDOMNode(this.refs.textbox); + const caret = Utils.getCaretPosition(textbox); + + const text = this.props.value; + const prefix = text.substring(0, caret - SuggestionStore.getMatchedPretext(this.suggestionId).length); + const suffix = text.substring(caret); + + if (this.props.onUserInput) { + this.props.onUserInput(prefix + term + ' ' + suffix); + } + + // set the caret position after the next rendering + window.requestAnimationFrame(() => { + Utils.setCaretPosition(textbox, prefix.length + term.length + 1); + }); + } + + handleKeyDown(e) { + if (SuggestionStore.hasSuggestions(this.suggestionId)) { + if (e.which === KeyCodes.UP) { + EventHelpers.emitSelectPreviousSuggestion(this.suggestionId); + e.preventDefault(); + } else if (e.which === KeyCodes.DOWN) { + EventHelpers.emitSelectNextSuggestion(this.suggestionId); + e.preventDefault(); + } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.TAB) { + EventHelpers.emitCompleteWordSuggestion(this.suggestionId); + e.preventDefault(); + } else if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } + } else if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } + } + + handlePretextChanged(pretext) { + for (const provider of this.props.providers) { + provider.handlePretextChanged(this.suggestionId, pretext); + } + } + + render() { + const newProps = Object.assign({}, this.props, { + onChange: this.handleChange, + onKeyDown: this.handleKeyDown + }); + + let textbox = null; + if (this.props.type === 'input') { + textbox = ( + <input + ref='textbox' + type='text' + {...newProps} + /> + ); + } else if (this.props.type === 'textarea') { + textbox = ( + <textarea + ref='textbox' + {...newProps} + /> + ); + } + + const SuggestionListComponent = this.props.listComponent; + + return ( + <div> + {textbox} + <SuggestionListComponent suggestionId={this.suggestionId} /> + </div> + ); + } +} + +SuggestionBox.defaultProps = { + type: 'input' +}; + +SuggestionBox.propTypes = { + listComponent: React.PropTypes.func.isRequired, + type: React.PropTypes.oneOf(['input', 'textarea']).isRequired, + value: React.PropTypes.string.isRequired, + onUserInput: React.PropTypes.func, + providers: React.PropTypes.arrayOf(React.PropTypes.object), + + // explicitly name any input event handlers we override and need to manually call + onChange: React.PropTypes.func, + onKeyDown: React.PropTypes.func +}; diff --git a/web/react/components/suggestion/suggestion_list.jsx b/web/react/components/suggestion/suggestion_list.jsx new file mode 100644 index 000000000..e3ccd0f08 --- /dev/null +++ b/web/react/components/suggestion/suggestion_list.jsx @@ -0,0 +1,125 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as EventHelpers from '../../dispatcher/event_helpers.jsx'; +import SuggestionStore from '../../stores/suggestion_store.jsx'; + +export default class SuggestionList extends React.Component { + constructor(props) { + super(props); + + this.getContent = this.getContent.bind(this); + + this.handleItemClick = this.handleItemClick.bind(this); + this.handleSuggestionsChanged = this.handleSuggestionsChanged.bind(this); + + this.scrollToItem = this.scrollToItem.bind(this); + + this.state = { + items: [], + terms: [], + components: [], + selection: '' + }; + } + + componentDidMount() { + SuggestionStore.addSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged); + } + + componentWillUnmount() { + SuggestionStore.removeSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged); + } + + getContent() { + return $(ReactDOM.findDOMNode(this.refs.content)); + } + + handleItemClick(term, e) { + EventHelpers.emitCompleteWordSuggestion(this.props.suggestionId, term); + + e.preventDefault(); + } + + handleSuggestionsChanged() { + const selection = SuggestionStore.getSelection(this.props.suggestionId); + + this.setState({ + items: SuggestionStore.getItems(this.props.suggestionId), + terms: SuggestionStore.getTerms(this.props.suggestionId), + components: SuggestionStore.getComponents(this.props.suggestionId), + selection + }); + + if (selection) { + window.requestAnimationFrame(() => this.scrollToItem(this.state.selection)); + } + } + + scrollToItem(term) { + const content = this.getContent(); + const visibleContentHeight = content[0].clientHeight; + const actualContentHeight = content[0].scrollHeight; + + if (visibleContentHeight < actualContentHeight) { + const contentTop = content.scrollTop(); + const contentTopPadding = parseInt(content.css('padding-top'), 10); + const contentBottomPadding = parseInt(content.css('padding-top'), 10); + + const item = $(ReactDOM.findDOMNode(this.refs[term])); + const itemTop = item[0].offsetTop - parseInt(item.css('margin-top'), 10); + const itemBottomMargin = parseInt(item.css('margin-bottom'), 10) + parseInt(item.css('padding-bottom'), 10); + const itemBottom = item[0].offsetTop + item.height() + itemBottomMargin; + + if (itemTop - contentTopPadding < contentTop) { + // the item is off the top of the visible space + content.scrollTop(itemTop - contentTopPadding); + } else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) { + // the item has gone off the bottom of the visible space + content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding); + } + } + } + + render() { + if (this.state.items.length === 0) { + return null; + } + + const items = []; + for (let i = 0; i < this.state.items.length; i++) { + const item = this.state.items[i]; + const term = this.state.terms[i]; + const isSelection = term === this.state.selection; + + // ReactComponent names need to be upper case when used in JSX + const Component = this.state.components[i]; + + items.push( + <Component + key={term} + ref={term} + item={item} + term={term} + isSelection={isSelection} + onClick={this.handleItemClick.bind(this, term)} + /> + ); + } + + return ( + <div className='suggestion-list suggestion-list--top'> + <div + ref='content' + className='suggestion-content suggestion-content--top' + > + {items} + </div> + </div> + ); + } +} + +SuggestionList.propTypes = { + suggestionId: React.PropTypes.string.isRequired +}; |