/* @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: () =>
,
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 (
);
},
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