2017-06-08_509bba0/509bba0_unpacked_with_node_modules/discord_app/components/SearchPopout.js
2022-07-26 10:06:20 -07:00

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}&nbsp;</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