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

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