/* @flow */ import React from 'react'; import Flux from '../lib/flux'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import ChannelStore from '../stores/ChannelStore'; import SelectedGuildStore from '../stores/SelectedGuildStore'; import SelectedChannelStore from '../stores/SelectedChannelStore'; import GuildMemberStore from '../stores/GuildMemberStore'; import UserSettingsStore from '../stores/UserSettingsStore'; import {clearHistory} from '../actions/SearchActionCreators'; import classNames from 'classnames'; import Moment from 'moment'; import {ComponentDispatch} from '../utils/ComponentDispatchUtils'; import SearchAutocompleteStore from '../stores/SearchAutocompleteStore'; import CalendarPicker from '../uikit/CalendarPicker'; import Tooltip from './common/Tooltip'; import SearchTokens, {getRandomDateShortcut} from '../lib/SearchTokens'; import lodash from 'lodash'; import * as SearchUtils from '../utils/SearchUtils'; import KeyboardShortcut from './common/KeyboardShortcut'; import HelpdeskUtils from '../utils/HelpdeskUtils'; import i18n from '../i18n'; import {NON_TOKEN_TYPE, Token} from '../lib/QueryTokenizer'; import { ComponentActions, SearchAutocompleteGroups, SearchTokenTypes, SearchPopoutModes, SEARCH_DATE_FORMAT, IS_SEARCH_ANSWER_TOKEN, IS_SEARCH_FILTER_TOKEN, } from '../Constants'; import type {CursorScope, SearchPopoutMode, ResultsGroup} from '../flow/Client'; import type UserRecord from '../records/UserRecord'; import '../styles/search_popout.styl'; const IS_WHITESPACE = /^\s*$/; const DISCORD_LAUNCH_DATE = Moment('2015-05-15').local(); function preventEditorBlur(event) { event.preventDefault(); event.stopPropagation(); } // We only want to automatically perform a search on replacement if the // replacement is not a filter key function isPerformSearchReplacement(replacement) { let performSearch = true; replacement = replacement.trim(); lodash(SearchTokens).forOwn(rule => { if (rule.key && replacement === rule.key) { performSearch = false; } }); return performSearch; } const renderUser = (searchId, group, {user, text}: {user: UserRecord, text: string}) => { if (!user) { return {text}; } const displayNick = GuildMemberStore.getNick(searchId, user.id) || user.username; const url = user.avatarURL || user.getAvatarURL(); return [ , {displayNick} , {text} , ]; }; const ResultComponent = ({ searchId, result, group, selected, className, onSelect, onFocus, showFilter, renderResult, }) => { let filter; if (showFilter) { filter = {SearchTokens[group].key || 'addme:'}; } let resultChildren; if (renderResult) { resultChildren = renderResult(searchId, group, result); } else { resultChildren = {result.text}; } return (
{filter} {resultChildren}
); }; const UserResultComponent = props => { return ; }; export const GroupData = { [SearchTokenTypes.FILTER_FROM]: { titleText: i18n.Messages.SEARCH_GROUP_HEADER_FROM, component: UserResultComponent, }, [SearchTokenTypes.FILTER_MENTIONS]: { titleText: i18n.Messages.SEARCH_GROUP_HEADER_MENTIONS, component: UserResultComponent, }, [SearchTokenTypes.FILTER_HAS]: { titleText: i18n.Messages.SEARCH_GROUP_HEADER_HAS, }, [SearchTokenTypes.FILTER_FILE_TYPE]: { titleText: i18n.Messages.SEARCH_GROUP_HEADER_FILE_TYPE, }, [SearchTokenTypes.FILTER_IN]: { titleText: i18n.Messages.SEARCH_GROUP_HEADER_CHANNELS, }, // TODO: Uncomment when the backend is ready // Currently not used since this feature is disabled for now // [SearchTokenTypes.FILTER_LINK_FROM]: { // titleText: i18n.Messages.SEARCH_GROUP_HEADER_LINK_FROM, // component: ({result, group, selected, onSelect, onFocus, showFilter}) => { // let filter; // if (showFilter) { // filter = {SearchTokens[group].key}; // } // return ( //
// {filter} // {result.text.split('.')[0]} //
// ); // } // },, [SearchAutocompleteGroups.DATES]: { titleText: i18n.Messages.SEARCH_GROUP_HEADER_DATES, }, [SearchAutocompleteGroups.HISTORY]: { titleText: i18n.Messages.SEARCH_GROUP_HEADER_HISTORY, groupTip: ({searchId}) =>
clearHistory(searchId)} className="search-clear-history"> {i18n.Messages.SEARCH_CLEAR_HISTORY}
, component: React.createClass({ mixins: [Flux.LazyStoreListenerMixin(ChannelStore)], getInitialState() { return this.getStateFromStores(); }, // We need to listen to the ChannelStore in case a channel is deleted/added. // If the channel is deleted, it gets set to a NON_TOKEN_TYPE getStateFromStores() { const tokens = SearchUtils.tokenizeQuery(this.props.result.text).map((token, i, tokens) => { if (!SearchUtils.filterHasAnswer(token, tokens[i + 1])) { return new Token(token.getFullMatch(), NON_TOKEN_TYPE); } return token; }); return { tokens, }; }, render() { const {selected, onSelect, onFocus} = this.props; const {tokens} = this.state; const history = tokens.map((token, i) => { const text = token.getFullMatch(); if (IS_WHITESPACE.test(text)) { return null; } const filter = IS_SEARCH_FILTER_TOKEN.test(token.type); const answer = IS_SEARCH_ANSWER_TOKEN.test(token.type); const nonText = !filter && !answer; return ( {text} ); }); return (
{history}
); }, }), }, [SearchAutocompleteGroups.SEARCH_OPTIONS]: { titleText: i18n.Messages.SEARCH_GROUP_HEADER_SEARCH_OPTIONS, groupTip: () =>
{i18n.Messages.LEARN_MORE}
, component: ({result, selected, onSelect, onFocus}) => { const answer = SearchUtils.SearchOptionAnswers[result.text]; return (
{result.text} {answer}
); }, }, }; type SearchPopoutStoreState = { searchId: ?string, selectedIndex: number, query: string, mode: SearchPopoutMode, tokens: Array, cursorScope: ?CursorScope, autocompletes: Array, totalResults: number, locale: string, }; const SearchPopout = React.createClass({ mixins: [ PureRenderMixin, Flux.LazyStoreListenerMixin(SelectedGuildStore, SelectedChannelStore, SearchAutocompleteStore, UserSettingsStore), ], getInitialState() { return { dateHint: getRandomDateShortcut(), ...this.getStateFromStores(), }; }, getStateFromStores(): SearchPopoutStoreState { const guildId = SelectedGuildStore.getGuildId(); const channelId = SelectedChannelStore.getChannelId(); const searchId = guildId || channelId; if (searchId == null) { throw new Error('SearchPopout.getStateFromStores - invalid searchId'); } const state = SearchAutocompleteStore.getState(searchId); const selectedIndex = this.state ? this.state.selectedIndex : 0; const totalResults = SearchUtils.getTotalResults(state.autocompletes); return { locale: UserSettingsStore.locale, searchId, selectedIndex, totalResults, ...state, }; }, componentDidUpdate(prevProps: {}, prevState: SearchPopoutStoreState) { const {mode, totalResults} = this.state; // Autoselect the first result when showing a filtered list if (mode.filter && !prevState.mode.filter && totalResults) { this.setState({selectedIndex: 1}); } else if (mode.type == SearchPopoutModes.FILTER_ALL && prevState.mode.type !== mode.type) { // Autoselect the search option when showing the empty state, coming from a different state this.setState({selectedIndex: 0}); } else { this.keepCurrentOptionSelected(prevState, this.state); } }, onDateChange(date: Moment) { this.setSearchQuery(date.format(SEARCH_DATE_FORMAT) + ' ', true); }, keepCurrentOptionSelected(prevState: SearchPopoutStoreState) { const {mode, selectedIndex, autocompletes, totalResults} = this.state; if (this.state.mode.type !== this.state.mode.type) { this.setState({selectedIndex: 0}); } else if ( prevState.selectedIndex > 0 && (prevState.selectedIndex === selectedIndex || prevState.autocompletes.length !== autocompletes.length) ) { const autocompleteList = SearchUtils.getFlattenedStringArray(prevState.autocompletes, prevState.mode.type); const currentOption = autocompleteList[prevState.selectedIndex - 1]; const newAutoCompleteList = SearchUtils.getFlattenedStringArray(autocompletes, mode.type); const newSelectedIndex = newAutoCompleteList.indexOf(currentOption); if (newSelectedIndex !== -1) { this.setState({selectedIndex: newSelectedIndex + 1}); } else if (prevState.selectedIndex > totalResults) { this.setState({selectedIndex: totalResults}); } } }, focusNextOption() { let {selectedIndex, mode} = this.state; // Keyboard navigation not allowed when date picker is shown if (SearchUtils.showDatePicker(mode && mode.filter)) { return; } selectedIndex += 1; this.focusOption(selectedIndex); }, focusPreviousOption() { let {selectedIndex, mode} = this.state; // Keyboard navigation not allowed when date picker is shown if (SearchUtils.showDatePicker(mode.filter)) { return; } selectedIndex -= 1; this.focusOption(selectedIndex); }, focusOption(selectedIndex: number) { const shouldShowSearchQuery = this.shouldShowSearchQuery(); if (selectedIndex < 0 || (!shouldShowSearchQuery && selectedIndex < 1)) { selectedIndex = SearchUtils.getTotalResults(this.state.autocompletes); } else if (selectedIndex > SearchUtils.getTotalResults(this.state.autocompletes)) { selectedIndex = shouldShowSearchQuery ? 0 : 1; } this.setState({selectedIndex}); }, selectOption(selectedIndex: ?number) { if (selectedIndex == null) { selectedIndex = this.state.selectedIndex; } if (!selectedIndex) { return false; } const {autocompletes, mode} = this.state; if (SearchUtils.showDatePicker(mode.filter)) { return; } const autocompleteList = SearchUtils.getFlattenedStringArray(autocompletes, mode.type); selectedIndex -= 1; // Escape hatch in case the index is out of bounds if (selectedIndex > autocompleteList.length) { return false; } const replacement = autocompleteList[selectedIndex]; const performSearch = isPerformSearchReplacement(replacement); this.setSearchQuery(replacement, performSearch); return true; }, setSearchQuery(query: string, performSearch: ?boolean = false) { const {mode, cursorScope} = this.state; let anchor = 0; if (mode.token) { anchor = mode.token.start; } else if (cursorScope && cursorScope.currentToken) { anchor = cursorScope.currentToken.end; } const focus = mode.token ? mode.token.end : anchor; ComponentDispatch.dispatch(ComponentActions.SET_SEARCH_QUERY, {query, anchor, focus, performSearch}); this.setState({selectedIndex: 0}); }, shouldShowSearchQuery() { const {mode} = this.state; return ( mode.type !== SearchPopoutModes.FILTER && mode.type !== SearchPopoutModes.EMPTY && !SearchUtils.showDatePicker(mode.filter) ); }, renderDatePicker() { return (
{i18n.Messages.SEARCH_DATE_PICKER_HINT}  {this.state.dateHint}
); }, handleHintClick() { this.setSearchQuery(this.state.dateHint, true); }, performSearch() { ComponentDispatch.dispatch(ComponentActions.PERFORM_SEARCH); }, renderSearchQuery() { const {selectedIndex, tokens} = this.state; const query = SearchUtils.getNonTokenQuery(tokens).trim(); if (!this.shouldShowSearchQuery()) { return; } const text = query ? i18n.Messages.SEARCH_FOR_VALUE.format({value: query}) : i18n.Messages.SEARCH_FOR_EMPTY; return (
{text}
); }, renderAutocompletes() { const {autocompletes, selectedIndex, mode, searchId} = this.state; if (SearchUtils.showDatePicker(mode.filter)) { return this.renderDatePicker(); } let counter = 0; return autocompletes.map(autocompleteGroup => { if (!autocompleteGroup || !autocompleteGroup.results.length) { return null; } const groupData = GroupData[autocompleteGroup.group] || {}; let header; if (groupData.titleText) { header = (
{groupData.titleText}
); } let GroupTip = groupData.groupTip || null; if (GroupTip) { GroupTip = ; } const Component = groupData.component || ResultComponent; const showFilter = mode.type === SearchPopoutModes.FILTER_ALL || false; return (
{header} {GroupTip} {autocompleteGroup.results.map(result => { if (!result || !autocompleteGroup) { return null; } counter += 1; return ( ); })}
); }); }, render() { return (
{this.renderSearchQuery()} {this.renderAutocompletes()}
); }, }); export default SearchPopout; // WEBPACK FOOTER // // ./discord_app/components/SearchPopout.js