/* @flow */
import React from 'react';
import classNames from 'classnames';
import Flux from '../lib/flux';
import i18n from '../i18n';
import ChannelSectionStore from '../stores/ChannelSectionStore';
import SelectedChannelStore from '../stores/SelectedChannelStore';
import SearchStore from '../stores/SearchStore';
import PopoutStore from '../stores/PopoutStore';
import ChannelStore from '../stores/ChannelStore';
import VideoThemeStore from '../stores/VideoThemeStore';
import Popout from './common/Popout';
import {SearchBarIcon} from './common/SearchBar';
import SearchPopout from './SearchPopout';
import {Editor} from 'draft-js';
import type {EditorState} from 'draft-js';
import * as SearchActionCreators from '../actions/SearchActionCreators';
import PopoutActionCreators from '../actions/PopoutActionCreators';
import {ComponentActions, SearchTokenTypes, ThemeTypes, SEARCH_POPOUT_ID} from '../Constants';
import * as DraftJSUtils from '../utils/DraftJSUtils';
import * as SearchUtils from '../utils/SearchUtils';
import * as DraftJSSearchUtils from '../utils/DraftJSSearchUtils';
import {ComponentDispatch} from '../utils/ComponentDispatchUtils';
import AppAnalyticsUtils from '../utils/AppAnalyticsUtils';
import QueryTokenizer from '../lib/QueryTokenizer';
import SearchTokens from '../lib/SearchTokens';
import lodash from 'lodash';
import 'draft-js/dist/Draft.css';
import '../styles/search.styl';
import '../styles/common/search_tokens.styl';
// Used to better position the autocomplete popout for search Certainly not
// perfect, but a start - some changes will need to happen in the Popout or
// Popouts components to better handle positioning in relation to animations,
// etc
const POPOUT_OFFSET = -12;
const MAX_LENGTH = 512;
const throttledSearch = lodash.throttle(SearchActionCreators.search, 500);
class Search extends React.PureComponent {
state = {
focused: false,
};
props: {
searchId: ?string,
isSearching: boolean,
editorState: ?EditorState,
hasResults: boolean,
theme: string,
};
_editorRef: ?Editor;
_popoutRef: ?Popout;
_searchPopoutRef: ?SearchPopout;
constructor(props) {
super(props);
lodash.bindAll(this, [
'search',
'handleSetSearchQuery',
'handleFocusSearch',
'handleClearSearch',
'focusEditor',
'blurEditor',
'setEditorRef',
'setPopoutRef',
'setSearchPopoutRef',
'renderPopout',
'onDownArrow',
'onUpArrow',
'onTab',
'onEscape',
'onBlur',
'onFocus',
'handleReturn',
'handleBeforeInput',
'handleKeyCommand',
'handlePastedText',
'handlePastedFiles',
'handleDroppedFiles',
'handleDrop',
'handleKeyBind',
'setEditorState',
]);
}
componentWillMount() {
SearchUtils.clearTokenCache();
}
componentDidMount() {
ComponentDispatch.subscribe(ComponentActions.PERFORM_SEARCH, this.search);
ComponentDispatch.subscribe(ComponentActions.SET_SEARCH_QUERY, this.handleSetSearchQuery);
ComponentDispatch.subscribe(ComponentActions.FOCUS_SEARCH, this.handleFocusSearch);
}
componentWillUpdate(nextProps) {
if (nextProps.searchId !== this.props.searchId) {
SearchUtils.clearTokenCache();
}
}
componentDidUpdate(prevProps) {
const {editorState, searchId} = this.props;
const {focused} = this.state;
if (focused) {
this.ensurePopoutIsOpen();
}
if (editorState !== prevProps.editorState) {
const tokens = SearchUtils.tokenizeQuery(DraftJSUtils.getFirstTextBlock(editorState));
const cursorScope = DraftJSSearchUtils.getSelectionScope(tokens, editorState);
SearchActionCreators.updateAutocompleteQuery(searchId, tokens, cursorScope);
if (this._editorRef) {
DraftJSUtils.scrollCursorIntoView(this._editorRef.refs.editor);
}
}
}
componentWillUnmount() {
ComponentDispatch.unsubscribe(ComponentActions.PERFORM_SEARCH, this.search);
ComponentDispatch.unsubscribe(ComponentActions.SET_SEARCH_QUERY, this.handleSetSearchQuery);
ComponentDispatch.unsubscribe(ComponentActions.FOCUS_SEARCH, this.handleFocusSearch);
}
handleSetSearchQuery({
query,
anchor,
focus,
performSearch,
replace,
}: {
query: string,
anchor?: number,
focus?: number,
performSearch?: boolean,
replace?: boolean,
}) {
let {editorState} = this.props;
const currentText = DraftJSUtils.getFirstTextBlock(editorState);
// Always append a space to an injected query if it doesn't already have one
if (query.charAt(query.length - 1) !== ' ') {
query += ' ';
}
// Always prepend a space to a query if it's not at anchor 0 and there
// isn't one beforehand already
if (anchor && currentText.charAt(anchor - 1) !== ' ' && query.charAt(0) !== ' ') {
query = ' ' + query;
}
if (replace) {
editorState = DraftJSUtils.replaceAllContent(query, editorState);
anchor = 0;
} else {
editorState = DraftJSUtils.updateContent(query, editorState, anchor, focus);
}
editorState = DraftJSUtils.truncateContent(editorState, MAX_LENGTH);
editorState = this.tokenize(editorState);
const offset = anchor + query.length;
editorState = DraftJSUtils.setCollapsedSelection(offset, editorState);
this.setEditorState(editorState);
if (performSearch) {
this.search(DraftJSUtils.getFirstTextBlock(editorState));
}
}
renderPopout() {
return ;
}
tokenize(editorState) {
const tokens = SearchUtils.tokenizeQuery(DraftJSUtils.getFirstTextBlock(editorState)).filter(
token => token.type !== QueryTokenizer.NON_TOKEN_TYPE
);
return DraftJSUtils.applyTokensAsEntities(tokens, editorState, SearchTokens);
}
search(queryString: ?string) {
const {searchId, isSearching} = this.props;
if (!queryString) {
const {editorState} = this.props;
queryString = DraftJSUtils.getFirstTextBlock(editorState);
}
if (searchId && !isSearching) {
const tokens = SearchUtils.tokenizeQuery(queryString);
const query = SearchUtils.getSearchQueryFromTokens(tokens);
// Remove any filters from the history that do not have answers
for (let i = 0; i < tokens.length; i++) {
if (!SearchUtils.filterHasAnswer(tokens[i], tokens[i + 1])) {
queryString = queryString.substring(0, tokens[i].start) + queryString.substring(tokens[i].end);
}
}
// If we have an empty query, abort the search
if (!tokens.length || Object.keys(query).length == 0) {
return false;
}
throttledSearch(searchId, query, queryString);
this.onBlur();
}
return true;
}
clearSearch() {
const {searchId} = this.props;
if (searchId) {
SearchActionCreators.clearSearchState(searchId);
}
}
handleClearSearch(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
this.clearSearch();
}
ensurePopoutIsOpen() {
if (this._popoutRef && !PopoutStore.isOpen(SEARCH_POPOUT_ID)) {
this._popoutRef.open();
}
}
handleFocusSearch({prefillCurrentChannel}: {prefillCurrentChannel?: boolean}) {
if (!prefillCurrentChannel || !DraftJSUtils.isEmpty(this.props.editorState)) {
this.focusEditor();
return;
}
const selectedChannelId = SelectedChannelStore.getChannelId();
const channel = ChannelStore.getChannel(selectedChannelId);
if (channel == null || channel.isPrivate()) {
this.focusEditor();
return;
}
this.handleSetSearchQuery({
query: SearchTokens[SearchTokenTypes.FILTER_IN].key + channel.toString(true) + ' ',
replace: true,
});
}
focusEditor() {
if (this._editorRef) {
// FIXME: Leaving for reference, probably don't need
// process.nextTick(this._editorRef.focus);
this._editorRef.focus();
}
}
blurEditor() {
if (this._editorRef) {
this._editorRef.blur();
}
}
setEditorRef(ref: Editor) {
this._editorRef = ref;
}
setPopoutRef(ref: Popout) {
this._popoutRef = ref;
}
setSearchPopoutRef(ref: SearchPopout) {
this._searchPopoutRef = ref;
}
onFocus() {
AppAnalyticsUtils.trackWithMetadata('search_opened');
this.setState({focused: true});
}
onBlur() {
this.setState({focused: false}, () => {
PopoutActionCreators.close(SEARCH_POPOUT_ID);
if (DraftJSUtils.isEmpty(this.props.editorState)) {
this.clearSearch();
}
});
}
onDownArrow(event: KeyboardEvent) {
event.preventDefault();
if (this._searchPopoutRef) {
this._searchPopoutRef.focusNextOption();
}
return true;
}
onUpArrow(event: KeyboardEvent) {
event.preventDefault();
if (this._searchPopoutRef) {
this._searchPopoutRef.focusPreviousOption();
}
return true;
}
onTab(event: KeyboardEvent) {
event.preventDefault();
if (this._searchPopoutRef) {
this._searchPopoutRef.selectOption();
}
return true;
}
handleReturn(event: KeyboardEvent) {
event.preventDefault();
let handled = false;
if (this._searchPopoutRef) {
handled = this._searchPopoutRef.selectOption();
}
if (!handled) {
this.search();
}
return true;
}
onEscape(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
this.blurEditor();
return true;
}
handleBeforeInput(character: string) {
let {editorState} = this.props;
const {focused} = this.state;
const query = DraftJSUtils.getFirstTextBlock(editorState);
if (query.length >= MAX_LENGTH) {
return true;
}
editorState = DraftJSUtils.updateContent(character, editorState);
// Probs never needed, but here as a safety (i.e. if character ever
// happened to be more than 1 char)
editorState = DraftJSUtils.truncateContent(editorState, MAX_LENGTH);
editorState = this.tokenize(editorState);
this.setEditorState(editorState);
if (!focused) {
this.setState({focused: true});
}
return true;
}
handleKeyCommand(command: string) {
let {editorState} = this.props;
const {focused} = this.state;
switch (command) {
case 'backspace':
case 'backspace-word':
case 'backspace-to-start-of-line':
case 'delete':
case 'delete-word':
editorState = DraftJSUtils.deleteContent(command, editorState);
editorState = this.tokenize(editorState);
this.setEditorState(editorState);
if (!focused) {
this.setState({focused: true});
}
return true;
case 'transpose-characters':
case 'move-selection-to-start-of-block':
case 'move-selection-to-end-of-block':
editorState = DraftJSUtils.miscCommand(command, editorState);
editorState = this.tokenize(editorState);
this.setEditorState(editorState);
return true;
// Misc commands that should never execute
case 'split-block':
case 'underline':
case 'bold':
case 'italic':
return true;
}
}
handlePastedText(text: string) {
let {editorState} = this.props;
const {focused} = this.state;
text = text.replace(/\n/g, '');
editorState = DraftJSUtils.updateContent(text, editorState);
editorState = DraftJSUtils.truncateContent(editorState, MAX_LENGTH);
editorState = this.tokenize(editorState);
if (!focused) {
this.setState({focused: true});
}
this.setEditorState(editorState);
return true;
}
handlePastedFiles() {
return true;
}
handleDroppedFiles() {
return true;
}
handleDrop() {
return true;
}
setEditorState(editorState) {
const {searchId} = this.props;
SearchActionCreators.setSearchState(searchId, editorState);
}
// Due to how `contenteditable` handles cmd-left/right and home/end, we have
// to manually set the cursor position to the beginning or end, otherwise the
// cursor will only jump by html block elements which is not useful
handleKeyBind(event: KeyboardEvent) {
const {key, metaKey, shiftKey} = event;
let {editorState} = this.props;
// // Appears to be a bug in DraftJS when certain commands propogate
event.stopPropagation();
if (key === 'Home' || (key === 'ArrowLeft' && metaKey)) {
event.preventDefault();
if (shiftKey) {
editorState = DraftJSUtils.setToStartSelection(editorState);
} else {
editorState = DraftJSUtils.setCollapsedStartSelection(editorState);
}
this.setEditorState(editorState);
return true;
}
if (key === 'End' || (key === 'ArrowRight' && metaKey)) {
event.preventDefault();
if (shiftKey) {
editorState = DraftJSUtils.setToEndSelection(editorState);
} else {
editorState = DraftJSUtils.setCollapsedEndSelection(editorState);
}
this.setEditorState(editorState);
return true;
}
// Ensure we properly handle all the rest of the inputs
return DraftJSUtils.getDefaultKeyBinding(event);
}
renderInput() {
const {editorState} = this.props;
return (
);
}
render() {
const {searchId, editorState, hasResults, theme} = this.props;
const {focused} = this.state;
const hasContent = !!DraftJSUtils.getFirstTextBlock(editorState).length;
if (!searchId) {
return null;
}
return (
);
}
}
export default Flux.connectStores([SearchStore, ChannelSectionStore, VideoThemeStore], () => {
const searchId = SearchStore.getCurrentSearchId();
const isSearching = searchId ? SearchStore.isSearching(searchId) : false;
const editorState =
(searchId && SearchStore.getEditorState(searchId)) ||
DraftJSUtils.createEmptyEditorState(DraftJSSearchUtils.generateDecorators(SearchTokens));
return {
searchId,
isSearching,
editorState,
hasResults: SearchStore.hasResults(searchId),
theme: VideoThemeStore.theme,
};
})(Search);
// WEBPACK FOOTER //
// ./discord_app/components/Search.js