From 12896bd23eeba79884245c1c29fdc568cf21a7fa Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Mon, 14 Mar 2016 08:50:46 -0400 Subject: Converting to Webpack. Stage 1. --- .../components/suggestion/at_mention_provider.jsx | 120 +++++++++++++++ webapp/components/suggestion/command_provider.jsx | 45 ++++++ webapp/components/suggestion/emoticon_provider.jsx | 93 ++++++++++++ .../suggestion/search_channel_provider.jsx | 71 +++++++++ .../suggestion/search_suggestion_list.jsx | 95 ++++++++++++ .../components/suggestion/search_user_provider.jsx | 64 ++++++++ webapp/components/suggestion/suggestion_box.jsx | 167 +++++++++++++++++++++ webapp/components/suggestion/suggestion_list.jsx | 129 ++++++++++++++++ 8 files changed, 784 insertions(+) create mode 100644 webapp/components/suggestion/at_mention_provider.jsx create mode 100644 webapp/components/suggestion/command_provider.jsx create mode 100644 webapp/components/suggestion/emoticon_provider.jsx create mode 100644 webapp/components/suggestion/search_channel_provider.jsx create mode 100644 webapp/components/suggestion/search_suggestion_list.jsx create mode 100644 webapp/components/suggestion/search_user_provider.jsx create mode 100644 webapp/components/suggestion/suggestion_box.jsx create mode 100644 webapp/components/suggestion/suggestion_list.jsx (limited to 'webapp/components/suggestion') diff --git a/webapp/components/suggestion/at_mention_provider.jsx b/webapp/components/suggestion/at_mention_provider.jsx new file mode 100644 index 000000000..b423528c3 --- /dev/null +++ b/webapp/components/suggestion/at_mention_provider.jsx @@ -0,0 +1,120 @@ +// 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'; + +import {FormattedMessage} from 'react-intl'; + +const MaxUserSuggestions = 40; + +import React from 'react'; + +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 = ( + + ); + icon = ; + } else if (item.username === 'channel') { + username = 'channel'; + description = ( + + ); + icon = ; + } else { + username = item.username; + description = Utils.getFullName(item); + icon = ( + +
+ {icon} +
+
+ + {'@' + username} + + + {description} + +
+ + ); + } +} + +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.getActiveOnlyProfiles(true); + let filtered = []; + + for (const id of Object.keys(users)) { + const user = users[id]; + + if (user.username.startsWith(usernamePrefix) && user.delete_at <= 0) { + filtered.push(user); + } + + if (filtered.length >= MaxUserSuggestions) { + break; + } + } + + // 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/webapp/components/suggestion/command_provider.jsx b/webapp/components/suggestion/command_provider.jsx new file mode 100644 index 000000000..36860fa66 --- /dev/null +++ b/webapp/components/suggestion/command_provider.jsx @@ -0,0 +1,45 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as AsyncClient from 'utils/async_client.jsx'; + +import React from 'react'; + +class CommandSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let className = 'command'; + if (isSelection) { + className += ' suggestion--selected'; + } + + return ( +
+
+ {item.suggestion} {item.hint} +
+
+ {item.description} +
+
+ ); + } +} + +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('/')) { + AsyncClient.getSuggestedCommands(pretext, suggestionId, CommandSuggestion); + } + } +} diff --git a/webapp/components/suggestion/emoticon_provider.jsx b/webapp/components/suggestion/emoticon_provider.jsx new file mode 100644 index 000000000..ab8b9376a --- /dev/null +++ b/webapp/components/suggestion/emoticon_provider.jsx @@ -0,0 +1,93 @@ +// 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; + +import React from 'react'; + +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 ( +
+
+ {text} +
+
+ {text} +
+
+ ); + } +} + +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/webapp/components/suggestion/search_channel_provider.jsx b/webapp/components/suggestion/search_channel_provider.jsx new file mode 100644 index 000000000..2e3195c1d --- /dev/null +++ b/webapp/components/suggestion/search_channel_provider.jsx @@ -0,0 +1,71 @@ +// 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'; + +import React from 'react'; + +class SearchChannelSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let className = 'search-autocomplete__item'; + if (isSelection) { + className += ' selected'; + } + + return ( +
+ {item.name} +
+ ); + } +} + +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/webapp/components/suggestion/search_suggestion_list.jsx b/webapp/components/suggestion/search_suggestion_list.jsx new file mode 100644 index 000000000..b15cc4243 --- /dev/null +++ b/webapp/components/suggestion/search_suggestion_list.jsx @@ -0,0 +1,95 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import ReactDOM from 'react-dom'; +import Constants from 'utils/constants.jsx'; +import SuggestionList from './suggestion_list.jsx'; + +import {FormattedMessage} from 'react-intl'; + +import {Popover} from 'react-bootstrap'; + +export default class SearchSuggestionList extends SuggestionList { + getContent() { + return $(ReactDOM.findDOMNode(this.refs.popover)).find('.popover-content'); + } + + renderChannelDivider(type) { + let text; + if (type === Constants.OPEN_CHANNEL) { + text = ( + + ); + } else { + text = ( + + ); + } + + return ( +
+ {text} +
+ ); + } + + 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( + + ); + } + + return ( + + {items} + + ); + } +} + +SearchSuggestionList.propTypes = { + ...SuggestionList.propTypes +}; diff --git a/webapp/components/suggestion/search_user_provider.jsx b/webapp/components/suggestion/search_user_provider.jsx new file mode 100644 index 000000000..eeaee68a7 --- /dev/null +++ b/webapp/components/suggestion/search_user_provider.jsx @@ -0,0 +1,64 @@ +// 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 React from 'react'; + +class SearchUserSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let className = 'search-autocomplete__item'; + if (isSelection) { + className += ' selected'; + } + + return ( +
+ {item.username} +
+ ); + } +} + +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/webapp/components/suggestion/suggestion_box.jsx b/webapp/components/suggestion/suggestion_box.jsx new file mode 100644 index 000000000..e3ec63194 --- /dev/null +++ b/webapp/components/suggestion/suggestion_box.jsx @@ -0,0 +1,167 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import ReactDOM from 'react-dom'; +import Constants from 'utils/constants.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; +import SuggestionStore from 'stores/suggestion_store.jsx'; +import * as Utils from 'utils/utils.jsx'; + +const KeyCodes = Constants.KeyCodes; + +import React from 'react'; + +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 + GlobalActions.emitClearSuggestions(this.suggestionId); + } + } + + handleChange(e) { + const textbox = ReactDOM.findDOMNode(this.refs.textbox); + const caret = Utils.getCaretPosition(textbox); + const pretext = textbox.value.substring(0, caret); + + GlobalActions.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) { + GlobalActions.emitSelectPreviousSuggestion(this.suggestionId); + e.preventDefault(); + } else if (e.which === KeyCodes.DOWN) { + GlobalActions.emitSelectNextSuggestion(this.suggestionId); + e.preventDefault(); + } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.TAB) { + GlobalActions.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 = ( + + ); + } else if (this.props.type === 'textarea') { + textbox = ( +