635 lines
18 KiB
JavaScript
Executable file
635 lines
18 KiB
JavaScript
Executable file
/* @flow */
|
|
|
|
import React from 'react';
|
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
|
import classNames from 'classnames';
|
|
import ChannelStore from '../stores/ChannelStore';
|
|
import RouterUtils from '../utils/RouterUtils';
|
|
import Popout from './common/Popout';
|
|
import Scroller from './common/Scroller';
|
|
import Spinner from './common/Spinner';
|
|
import Tooltip from './common/Tooltip';
|
|
import MessageGroup from './MessageGroup';
|
|
import i18n from '../i18n';
|
|
import Flux from '../lib/flux';
|
|
import MessageActionCreators from '../actions/MessageActionCreators';
|
|
import * as SearchActionCreators from '../actions/SearchActionCreators';
|
|
import AlertActionCreators from '../actions/AlertActionCreators';
|
|
import InstantInviteActionCreators from '../actions/InstantInviteActionCreators';
|
|
import SearchStore from '../stores/SearchStore';
|
|
import SelectedChannelStore from '../stores/SelectedChannelStore';
|
|
import UserSettingsStore from '../stores/UserSettingsStore';
|
|
import RelationshipStore from '../stores/RelationshipStore';
|
|
import AppAnalyticsUtils from '../utils/AppAnalyticsUtils';
|
|
import HelpdeskUtils from '../utils/HelpdeskUtils';
|
|
import lodash from 'lodash';
|
|
import SearchIndexingAnimation from './common/animated/SearchIndexingAnimation';
|
|
import {Routes, SearchModes, SEARCH_PAGE_SIZE, SearchTypes} from '../Constants';
|
|
import type MessageRecord from '../records/MessageRecord';
|
|
import '../styles/search_results.styl';
|
|
|
|
const MESSAGE_REF = 'MESSAGE_REF';
|
|
const SCROLLER_REF = 'SCROLLER_REF';
|
|
const HIT_REF = 'HIT_REF';
|
|
|
|
const Pagination = ({searchNext, searchPrevious, offset, totalResults, pageLength}) => {
|
|
const totalPages = Math.ceil(totalResults / pageLength);
|
|
const page = offset / pageLength + 1;
|
|
const pagePretty = page.toLocaleString();
|
|
const totalPretty = totalPages.toLocaleString();
|
|
|
|
if (totalPages === 1) {
|
|
return null;
|
|
}
|
|
|
|
const canSearchPrev = page > 1;
|
|
const canSearchNext = page < totalPages;
|
|
|
|
return (
|
|
<div className="pagination">
|
|
<div
|
|
onClick={canSearchPrev && searchPrevious}
|
|
className={classNames('pagination-previous', {disabled: !canSearchPrev})}
|
|
/>
|
|
{i18n.Messages.PAGINATION_PAGE_OF.format({page: pagePretty, totalPages: totalPretty})}
|
|
<div
|
|
onClick={canSearchNext && searchNext}
|
|
className={classNames('pagination-next', {disabled: !canSearchNext})}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SearchHeader = ({searchMode, searchByMode, totalResults, isSearching, isIndexing, documentsIndexed}) => {
|
|
const tabs = lodash(SearchModes)
|
|
.values()
|
|
.map((mode: string) => {
|
|
let message;
|
|
switch (mode) {
|
|
case SearchModes.RECENT:
|
|
message = i18n.Messages.SEARCH_RECENT;
|
|
break;
|
|
case SearchModes.RELEVANT:
|
|
message = i18n.Messages.SEARCH_RELEVANT;
|
|
break;
|
|
}
|
|
return (
|
|
<div
|
|
className={classNames('tab', {selected: searchMode === mode})}
|
|
onClick={searchByMode.bind(null, mode)}
|
|
key={mode}>
|
|
{message}
|
|
</div>
|
|
);
|
|
})
|
|
.value();
|
|
|
|
let totalContent;
|
|
if (isSearching) {
|
|
totalContent = i18n.Messages.SEARCHING;
|
|
} else if (isIndexing) {
|
|
totalContent = (
|
|
<a href={HelpdeskUtils.getArticleURL(115000414847)} target="_blank">
|
|
{i18n.Messages.STILL_INDEXING}
|
|
</a>
|
|
);
|
|
} else {
|
|
const count = totalResults.toLocaleString();
|
|
totalContent = i18n.Messages.TOTAL_RESULTS.format({count});
|
|
}
|
|
|
|
let loadingIndicator;
|
|
if (isIndexing || isSearching) {
|
|
loadingIndicator = (
|
|
<div className="spinner-wrapper">
|
|
<Spinner type="spinning-circle" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isIndexing && !isSearching) {
|
|
totalContent = (
|
|
<Tooltip text={i18n.Messages.SEARCH_STILL_INDEXING_HINT.format({count: documentsIndexed})}>
|
|
<div className="total-results-wrapper">
|
|
{totalContent}
|
|
{loadingIndicator}
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
loadingIndicator = null;
|
|
}
|
|
|
|
return (
|
|
<div className="search-header">
|
|
<div className="total-results">
|
|
{totalContent}
|
|
{loadingIndicator}
|
|
</div>
|
|
{tabs}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SearchResultsViewedAutoAnalytics = React.createClass({
|
|
componentDidMount() {
|
|
this.autoAnalytics();
|
|
},
|
|
|
|
componentDidUpdate(prevProps) {
|
|
if (
|
|
this.props.searchAnalyticsId !== prevProps.searchAnalyticsId ||
|
|
this.props.searchOffset !== prevProps.searchOffset
|
|
) {
|
|
this.autoAnalytics(prevProps.searchAnalyticsId);
|
|
}
|
|
},
|
|
|
|
autoAnalytics(searchAnalyticsId = null) {
|
|
if (this.props.searchAnalyticsId == null) {
|
|
return;
|
|
}
|
|
|
|
let numMessages = 0;
|
|
let numAttachments = 0;
|
|
let numEmbeds = 0;
|
|
let numLinks = 0;
|
|
|
|
if (this.props.searchResults) {
|
|
lodash(this.props.searchResults).flatten().filter(r => r.hit).forEach(({message}) => {
|
|
if (message.content) {
|
|
numMessages++;
|
|
if (/https?:\/\/[^\s]+/.test(message.content)) {
|
|
numLinks++;
|
|
}
|
|
}
|
|
if (message.embeds && message.embeds.length) {
|
|
numEmbeds++;
|
|
}
|
|
if (message.attachments && message.attachments.length) {
|
|
numAttachments++;
|
|
}
|
|
});
|
|
}
|
|
|
|
AppAnalyticsUtils.trackWithMetadata('search_result_viewed', {
|
|
/* eslint-disable camelcase */
|
|
search_id: this.props.searchAnalyticsId,
|
|
prev_search_id: searchAnalyticsId !== this.props.searchAnalyticsId ? searchAnalyticsId : null,
|
|
is_error: this.props.searchHasError,
|
|
limit: this.props.searchLimit,
|
|
offset: this.props.searchOffset,
|
|
page: Math.floor(this.props.searchOffset / this.props.searchLimit) + 1,
|
|
total_results: this.props.searchTotalResults,
|
|
page_results: this.props.searchResults ? this.props.searchResults.length : null,
|
|
is_indexing: this.props.isIndexing,
|
|
page_num_messages: numMessages,
|
|
page_num_links: numLinks,
|
|
page_num_embeds: numEmbeds,
|
|
page_num_attach: numAttachments,
|
|
/* eslint-enable camelcase */
|
|
});
|
|
},
|
|
|
|
render() {
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const EmptyResults = ({children}) =>
|
|
<div className="empty-results-wrap">
|
|
<div className="empty-results-content">
|
|
{children}
|
|
</div>
|
|
</div>;
|
|
|
|
const SearchResult = React.createClass({
|
|
mixins: [PureRenderMixin],
|
|
|
|
getInitialState() {
|
|
return {
|
|
expanded: false,
|
|
};
|
|
},
|
|
|
|
componentWillUpdate(nextProps, nextState) {
|
|
const {expanded} = this.state;
|
|
if (expanded !== nextState.expanded) {
|
|
// $FlowFixMe
|
|
this._height = this.getDOMRect(this.refs[HIT_REF]).top;
|
|
}
|
|
},
|
|
|
|
componentDidUpdate(prevProps, prevState) {
|
|
const {expanded} = this.state;
|
|
if (expanded !== prevState.expanded) {
|
|
this.fixScroll();
|
|
}
|
|
},
|
|
|
|
getDOMRect(domNode) {
|
|
return domNode.getClientRects()[0];
|
|
},
|
|
|
|
fixScroll() {
|
|
const {expanded} = this.state;
|
|
// $FlowFixMe
|
|
const prevHeight = this._height;
|
|
// $FlowFixMe
|
|
this._height = this.getDOMRect(this.refs[HIT_REF]).top;
|
|
const {scrollTo} = this.props;
|
|
|
|
if (expanded) {
|
|
scrollTo(prevHeight - this._height);
|
|
} else {
|
|
scrollTo(prevHeight - this._height);
|
|
}
|
|
},
|
|
|
|
renderControlButtons(message) {
|
|
return (
|
|
<div className="action-buttons">
|
|
<div className="jump-button" onClick={this.props.onJump.bind(null, message)}>
|
|
{i18n.Messages.JUMP}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
|
|
expand() {
|
|
const expanded = !this.state.expanded;
|
|
this.setState({expanded});
|
|
if (expanded) {
|
|
AppAnalyticsUtils.trackWithMetadata('search_result_expanded', {
|
|
/* eslint-disable camelcase */
|
|
message_id: this.props.result.filter(r => r.hit)[0].message.id,
|
|
search_id: SearchStore.getAnalyticsId(this.props.searchId),
|
|
limit: SEARCH_PAGE_SIZE,
|
|
offset: this.props.searchOffset,
|
|
page: Math.floor(this.props.seachOffset / SEARCH_PAGE_SIZE) + 1,
|
|
page_results: this.props.pageResultsLength,
|
|
result_index: this.props.index,
|
|
/* eslint-enable camelcase */
|
|
});
|
|
}
|
|
},
|
|
|
|
render() {
|
|
const {expanded} = this.state;
|
|
const {result, developerMode, renderEmbeds} = this.props;
|
|
|
|
let before = true;
|
|
// Creates an array of components for each messageGroup, additionally we do
|
|
// some calculations to determine context/position.
|
|
const nodes = result.map((searchResult, index) => {
|
|
const {message, hit} = searchResult;
|
|
const channel = ChannelStore.getChannel(message.channel_id);
|
|
|
|
before = before && !hit;
|
|
const after = !before && !hit;
|
|
|
|
let sibling;
|
|
if (!hit && before && result[index + 1].hit) {
|
|
sibling = true;
|
|
} else if (!hit && !before && result[index - 1].hit) {
|
|
sibling = true;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={hit ? HIT_REF : null}
|
|
className={classNames('search-result-message', {
|
|
hit,
|
|
sibling,
|
|
before,
|
|
after,
|
|
})}
|
|
key={message.id}>
|
|
<MessageGroup
|
|
messages={[message]}
|
|
channel={channel}
|
|
inlineAttachmentMedia={true}
|
|
inlineEmbedMedia={true}
|
|
renderEmbeds={renderEmbeds}
|
|
developerMode={developerMode}
|
|
popoutPosition={Popout.LEFT}
|
|
avatarSize="large"
|
|
groupOption={hit ? this.renderControlButtons.bind(null, message) : null}
|
|
onClickAnywhere={this.expand}
|
|
canEdit={false}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<div ref={MESSAGE_REF} className={classNames('search-result', {expanded})}>
|
|
{nodes}
|
|
</div>
|
|
);
|
|
},
|
|
});
|
|
|
|
const SearchResults = React.createClass({
|
|
mixins: [PureRenderMixin, Flux.LazyStoreListenerMixin(SearchStore, UserSettingsStore)],
|
|
|
|
propTypes: {
|
|
searchId: React.PropTypes.string.isRequired,
|
|
},
|
|
|
|
getInitialState() {
|
|
return {
|
|
searchMode: SearchModes.RECENT,
|
|
...this.getStateFromStores(),
|
|
};
|
|
},
|
|
|
|
getStateFromStores() {
|
|
const {searchId} = this.props;
|
|
return this.getStateForSearchId(searchId);
|
|
},
|
|
|
|
componentWillReceiveProps(nextProps: {searchId: string}) {
|
|
const {searchId} = this.props;
|
|
if (nextProps.searchId !== searchId) {
|
|
this.setState(this.getStateForSearchId(nextProps.searchId));
|
|
}
|
|
},
|
|
|
|
componentDidUpdate(prevProps: {}, {showBlockedResults}: {showBlockedResults: boolean}) {
|
|
if (this.state.showBlockedResults !== showBlockedResults) {
|
|
const scroller = this.refs[SCROLLER_REF];
|
|
if (!scroller) {
|
|
return;
|
|
}
|
|
scroller.scrollToBottom();
|
|
}
|
|
},
|
|
|
|
getStateForSearchId(searchId: string) {
|
|
return {
|
|
searchId,
|
|
theme: UserSettingsStore.theme,
|
|
renderEmbeds: UserSettingsStore.renderEmbeds,
|
|
developerMode: UserSettingsStore.developerMode,
|
|
searchAnalyticsId: SearchStore.getAnalyticsId(searchId),
|
|
...SearchStore.getResultsState(searchId),
|
|
};
|
|
},
|
|
|
|
jumpToMessage(message: MessageRecord) {
|
|
if (message.blocked) {
|
|
AlertActionCreators.show({
|
|
title: i18n.Messages.UNBLOCK_TO_JUMP_TITLE,
|
|
body: i18n.Messages.UNBLOCK_TO_JUMP_BODY.format({name: message.author.username}),
|
|
confirmText: i18n.Messages.OKAY,
|
|
});
|
|
} else {
|
|
const channel = ChannelStore.getChannel(message.channel_id);
|
|
const guildId = channel ? channel.getGuildId() : null;
|
|
MessageActionCreators.trackJump(message.channel_id, message.id, 'Search Results', {
|
|
// eslint-disable-next-line camelcase
|
|
search_id: SearchStore.getAnalyticsId(this.state.searchId),
|
|
});
|
|
RouterUtils.transitionTo(Routes.MESSAGE(guildId, message.channel_id, message.id));
|
|
}
|
|
},
|
|
|
|
selectChannel(channelId: string) {
|
|
if (channelId !== SelectedChannelStore.getChannelId()) {
|
|
InstantInviteActionCreators.transitionToInviteChannelSync(channelId);
|
|
}
|
|
},
|
|
|
|
searchPrevious() {
|
|
const {searchId} = this.props;
|
|
const {isSearching} = this.state;
|
|
if (!isSearching) {
|
|
SearchActionCreators.searchPreviousPage(searchId);
|
|
}
|
|
},
|
|
|
|
searchNext() {
|
|
const {searchId} = this.props;
|
|
const {isSearching} = this.state;
|
|
if (!isSearching) {
|
|
SearchActionCreators.searchNextPage(searchId);
|
|
}
|
|
},
|
|
|
|
searchByMode(mode: string) {
|
|
const {searchId} = this.props;
|
|
const {searchMode, isSearching} = this.state;
|
|
|
|
if (mode != searchMode && !isSearching) {
|
|
AppAnalyticsUtils.trackWithMetadata('search_result_sort_changed', {
|
|
/* eslint-disable camelcase */
|
|
search_id: SearchStore.getAnalyticsId(this.props.searchId),
|
|
new_sort_type: mode,
|
|
/* eslint-enable camelcase */
|
|
});
|
|
SearchActionCreators.searchByMode(searchId, mode);
|
|
this.setState({searchMode: mode});
|
|
}
|
|
},
|
|
|
|
toggleShowBlockedMessages() {
|
|
const {searchId, showBlockedResults} = this.state;
|
|
SearchActionCreators.setShowBlockedResults(searchId, !showBlockedResults);
|
|
},
|
|
|
|
renderHeader() {
|
|
const {searchMode, totalResults, isSearching, isHistoricalIndexing, documentsIndexed} = this.state;
|
|
return (
|
|
<SearchHeader
|
|
searchMode={searchMode}
|
|
searchByMode={this.searchByMode}
|
|
totalResults={totalResults}
|
|
isSearching={isSearching}
|
|
isIndexing={isHistoricalIndexing}
|
|
documentsIndexed={documentsIndexed}
|
|
/>
|
|
);
|
|
},
|
|
|
|
renderResults() {
|
|
const {results, offset, renderEmbeds, developerMode, totalResults, isSearching, showBlockedResults} = this.state;
|
|
|
|
// Fix for flow - but theoretically this should never be possible
|
|
if (!results) {
|
|
return null;
|
|
}
|
|
const searchResults = [];
|
|
let lastChannel;
|
|
const resultsLength = results.length;
|
|
|
|
results.forEach((result, i) => {
|
|
const hit = result.find(message => message.hit);
|
|
|
|
if (!showBlockedResults && hit && RelationshipStore.isBlocked(hit.message.author.id)) {
|
|
return;
|
|
}
|
|
|
|
const channel = ChannelStore.getChannel(result[0].message.channel_id);
|
|
let separator;
|
|
if (channel && (!lastChannel || lastChannel.id != channel.id)) {
|
|
separator = (
|
|
<div className="channel-separator" key={`channel-separator-${i}`}>
|
|
<span className="channel-name" onClick={this.selectChannel.bind(null, channel.id)}>
|
|
{channel ? channel.toString(true) : '???'}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const resultNode = (
|
|
<SearchResult
|
|
scrollTo={this.scrollTo}
|
|
searchId={this.props.searchId}
|
|
renderEmbeds={renderEmbeds}
|
|
developerMode={developerMode}
|
|
searchOffset={this.state.offset}
|
|
pageResultsLength={resultsLength}
|
|
result={result}
|
|
index={i}
|
|
key={`search-result-${i}`}
|
|
onJump={this.jumpToMessage}
|
|
/>
|
|
);
|
|
|
|
searchResults.push(separator, resultNode);
|
|
lastChannel = channel;
|
|
});
|
|
|
|
let pagination;
|
|
if (!isSearching) {
|
|
pagination = (
|
|
<Pagination
|
|
searchPrevious={this.searchPrevious}
|
|
searchNext={this.searchNext}
|
|
offset={offset}
|
|
totalResults={totalResults}
|
|
pageLength={SEARCH_PAGE_SIZE}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="results-wrapper">
|
|
<div className="search-results">
|
|
{searchResults}
|
|
{this.renderResultsBlocked()}
|
|
</div>
|
|
{pagination}
|
|
</div>
|
|
);
|
|
},
|
|
|
|
renderResultsBlocked() {
|
|
const {resultsBlocked, showBlockedResults} = this.state;
|
|
if (!resultsBlocked) {
|
|
return null;
|
|
}
|
|
|
|
const message = showBlockedResults
|
|
? i18n.Messages.SEARCH_HIDE_BLOCKED_MESSAGES.format({count: resultsBlocked})
|
|
: i18n.Messages.SEARCH_NUM_RESULTS_BLOCKED_NOT_SHOWN.format({count: resultsBlocked});
|
|
|
|
return (
|
|
<button className="results-blocked" onClick={this.toggleShowBlockedMessages}>
|
|
<div className="results-blocked-image" />
|
|
<div className="results-blocked-text">
|
|
{message}
|
|
</div>
|
|
</button>
|
|
);
|
|
},
|
|
|
|
renderIndexing() {
|
|
const searchType = SearchStore.getSearchType(this.props.searchId);
|
|
const message = searchType === SearchTypes.GUILD
|
|
? i18n.Messages.SEARCH_GUILD_STILL_INDEXING
|
|
: i18n.Messages.SEARCH_DM_STILL_INDEXING;
|
|
|
|
return (
|
|
<EmptyResults>
|
|
<SearchIndexingAnimation />
|
|
<div className="empty-results-text still-indexing">{message}</div>
|
|
</EmptyResults>
|
|
);
|
|
},
|
|
|
|
renderNoResults() {
|
|
const {showNoResultsAlt: alt} = this.state;
|
|
|
|
const message = alt ? i18n.Messages.SEARCH_NO_RESULTS_ALT : i18n.Messages.SEARCH_NO_RESULTS;
|
|
|
|
return (
|
|
<EmptyResults>
|
|
<div className={classNames('no-results-image', {alt})} />
|
|
<div className={classNames('empty-results-text no-results', {alt})}>{message}</div>
|
|
</EmptyResults>
|
|
);
|
|
},
|
|
|
|
renderError() {
|
|
return (
|
|
<EmptyResults>
|
|
<div className="error-image" />
|
|
<div className="empty-results-text error-message">{i18n.Messages.SEARCH_ERROR}</div>
|
|
</EmptyResults>
|
|
);
|
|
},
|
|
|
|
renderContent() {
|
|
const {totalResults, isSearching, isIndexing, hasError} = this.state;
|
|
if (hasError) {
|
|
return this.renderError();
|
|
}
|
|
if (isIndexing) {
|
|
return this.renderIndexing();
|
|
}
|
|
if (isSearching) {
|
|
return null;
|
|
}
|
|
if (totalResults) {
|
|
return this.renderResults();
|
|
}
|
|
return this.renderNoResults();
|
|
},
|
|
|
|
render() {
|
|
return (
|
|
<div className="search-results-wrap">
|
|
{this.renderHeader()}
|
|
<Scroller ref={SCROLLER_REF} theme={this.state.theme}>
|
|
{this.renderContent()}
|
|
</Scroller>
|
|
<SearchResultsViewedAutoAnalytics
|
|
searchAnalyticsId={this.state.searchAnalyticsId}
|
|
searchResults={this.state.results}
|
|
searchOffset={this.state.offset}
|
|
searchLimit={SEARCH_PAGE_SIZE}
|
|
searchHasError={this.state.hasError}
|
|
searchTotalResults={this.state.totalResults}
|
|
searchIsIndexing={this.state.isHistoricalIndexing}
|
|
/>
|
|
</div>
|
|
);
|
|
},
|
|
|
|
scrollTo(offset: number) {
|
|
const scroller = this.refs[SCROLLER_REF];
|
|
if (!scroller) {
|
|
return;
|
|
}
|
|
const scrollTop = scroller.getScrollData().scrollTop - offset;
|
|
scroller.scrollTo(scrollTop);
|
|
},
|
|
});
|
|
|
|
export default SearchResults;
|
|
|
|
|
|
|
|
// WEBPACK FOOTER //
|
|
// ./discord_app/components/SearchResults.js
|