505 lines
16 KiB
JavaScript
Executable file
505 lines
16 KiB
JavaScript
Executable file
/* @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 <strong>{text}</strong>;
|
|
}
|
|
const displayNick = GuildMemberStore.getNick(searchId, user.id) || user.username;
|
|
const url = user.avatarURL || user.getAvatarURL();
|
|
return [
|
|
<img key={`avatar-${group}-${user.id}`} className="display-avatar" src={url} width={18} height={18} />,
|
|
<span key={`display-nick-${group}-${user.id}`} className="displayed-nick">
|
|
{displayNick}
|
|
</span>,
|
|
<span key={`display-username-${group}-${user.id}`} className="display-username">
|
|
{text}
|
|
</span>,
|
|
];
|
|
};
|
|
|
|
const ResultComponent = ({
|
|
searchId,
|
|
result,
|
|
group,
|
|
selected,
|
|
className,
|
|
onSelect,
|
|
onFocus,
|
|
showFilter,
|
|
renderResult,
|
|
}) => {
|
|
let filter;
|
|
if (showFilter) {
|
|
filter = <span className="filter">{SearchTokens[group].key || 'addme:'}</span>;
|
|
}
|
|
let resultChildren;
|
|
if (renderResult) {
|
|
resultChildren = renderResult(searchId, group, result);
|
|
} else {
|
|
resultChildren = <strong>{result.text}</strong>;
|
|
}
|
|
return (
|
|
<div className={classNames('option', className, {selected})} onClick={onSelect} onMouseOver={onFocus}>
|
|
{filter}
|
|
{resultChildren}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const UserResultComponent = props => {
|
|
return <ResultComponent {...props} className="user" renderResult={renderUser} />;
|
|
};
|
|
|
|
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 = <span className="filter">{SearchTokens[group].key}</span>;
|
|
// }
|
|
// return (
|
|
// <div
|
|
// className={classNames('option', 'link-source', {selected})}
|
|
// onClick={onSelect}
|
|
// onMouseOver={onFocus}>
|
|
// {filter}
|
|
// <strong>{result.text.split('.')[0]}</strong>
|
|
// </div>
|
|
// );
|
|
// }
|
|
// },,
|
|
[SearchAutocompleteGroups.DATES]: {
|
|
titleText: i18n.Messages.SEARCH_GROUP_HEADER_DATES,
|
|
},
|
|
[SearchAutocompleteGroups.HISTORY]: {
|
|
titleText: i18n.Messages.SEARCH_GROUP_HEADER_HISTORY,
|
|
groupTip: ({searchId}) =>
|
|
<Tooltip text={i18n.Messages.SEARCH_CLEAR_HISTORY} type="normal" position="left">
|
|
<div onClick={() => clearHistory(searchId)} className="search-clear-history">
|
|
{i18n.Messages.SEARCH_CLEAR_HISTORY}
|
|
</div>
|
|
</Tooltip>,
|
|
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 (
|
|
<span className={classNames({filter, answer, 'non-text': nonText})} key={i}>
|
|
{text}
|
|
</span>
|
|
);
|
|
});
|
|
return (
|
|
<div className={classNames('option history', {selected})} onClick={onSelect} onMouseOver={onFocus}>
|
|
{history}
|
|
</div>
|
|
);
|
|
},
|
|
}),
|
|
},
|
|
[SearchAutocompleteGroups.SEARCH_OPTIONS]: {
|
|
titleText: i18n.Messages.SEARCH_GROUP_HEADER_SEARCH_OPTIONS,
|
|
groupTip: () =>
|
|
<Tooltip text={i18n.Messages.LEARN_MORE} type="normal" position="left">
|
|
<div className="search-learn-more">
|
|
<a href={HelpdeskUtils.getArticleURL(115000468588)} target="_blank">{i18n.Messages.LEARN_MORE}</a>
|
|
</div>
|
|
</Tooltip>,
|
|
component: ({result, selected, onSelect, onFocus}) => {
|
|
const answer = SearchUtils.SearchOptionAnswers[result.text];
|
|
return (
|
|
<div className={classNames('option search-option', {selected})} onClick={onSelect} onMouseOver={onFocus}>
|
|
<span className="filter">{result.text}</span>
|
|
<span className="answer">{answer}</span>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
};
|
|
|
|
type SearchPopoutStoreState = {
|
|
searchId: ?string,
|
|
selectedIndex: number,
|
|
query: string,
|
|
mode: SearchPopoutMode,
|
|
tokens: Array<Token>,
|
|
cursorScope: ?CursorScope,
|
|
autocompletes: Array<?ResultsGroup>,
|
|
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 (
|
|
<div className="date-picker">
|
|
<CalendarPicker
|
|
locale={this.state.locale}
|
|
onChange={this.onDateChange}
|
|
maxDate={Moment().local()}
|
|
minDate={DISCORD_LAUNCH_DATE}
|
|
/>
|
|
<div className="date-picker-hint">
|
|
<span className="hint">{i18n.Messages.SEARCH_DATE_PICKER_HINT} </span>
|
|
<span className="hint-value" onClick={this.handleHintClick}>{this.state.dateHint}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
|
|
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 (
|
|
<div
|
|
className={classNames('option', 'search-query', {selected: selectedIndex === 0})}
|
|
onMouseOver={this.focusOption.bind(null, 0)}
|
|
onClick={this.performSearch}>
|
|
<div className="search-for">
|
|
{text}
|
|
</div>
|
|
<KeyboardShortcut shortcut="return" className="dim" />
|
|
</div>
|
|
);
|
|
},
|
|
|
|
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 = (
|
|
<div className="header">
|
|
{groupData.titleText}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
let GroupTip = groupData.groupTip || null;
|
|
if (GroupTip) {
|
|
GroupTip = <GroupTip searchId={searchId} />;
|
|
}
|
|
|
|
const Component = groupData.component || ResultComponent;
|
|
const showFilter = mode.type === SearchPopoutModes.FILTER_ALL || false;
|
|
return (
|
|
<div className="results-group" key={autocompleteGroup.group}>
|
|
{header}
|
|
{GroupTip}
|
|
{autocompleteGroup.results.map(result => {
|
|
if (!result || !autocompleteGroup) {
|
|
return null;
|
|
}
|
|
counter += 1;
|
|
return (
|
|
<Component
|
|
key={`${autocompleteGroup.group}-${result.text}-${result.key || ''}`}
|
|
searchId={searchId}
|
|
group={result.group || autocompleteGroup.group}
|
|
result={result}
|
|
showFilter={showFilter}
|
|
selected={selectedIndex === counter}
|
|
onSelect={this.selectOption.bind(null, counter)}
|
|
onFocus={this.focusOption.bind(null, counter)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
});
|
|
},
|
|
|
|
render() {
|
|
return (
|
|
<div className="theme-light search-popout" onMouseDown={preventEditorBlur}>
|
|
{this.renderSearchQuery()}
|
|
{this.renderAutocompletes()}
|
|
</div>
|
|
);
|
|
},
|
|
});
|
|
|
|
export default SearchPopout;
|
|
|
|
|
|
|
|
// WEBPACK FOOTER //
|
|
// ./discord_app/components/SearchPopout.js
|