Signal-Desktop/ts/components/leftPane/LeftPaneSearchHelper.tsx

393 lines
12 KiB
TypeScript

// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactChild } from 'react';
import React from 'react';
import type { ToFindType } from './LeftPaneHelper';
import { LeftPaneHelper } from './LeftPaneHelper';
import type { LocalizerType } from '../../types/Util';
import type { Row } from '../ConversationList';
import { RowType } from '../ConversationList';
import type { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem';
import { handleKeydownForSearch } from './handleKeydownForSearch';
import type {
ConversationType,
ShowConversationType,
} from '../../state/ducks/conversations';
import { LeftPaneSearchInput } from '../LeftPaneSearchInput';
import { Intl } from '../Intl';
import { Emojify } from '../conversation/Emojify';
import { assertDev } from '../../util/assert';
// The "correct" thing to do is to measure the size of the left pane and render enough
// search results for the container height. But (1) that's slow (2) the list is
// virtualized (3) 99 rows is over 7500px tall, taller than most monitors (4) it's fine
// if, in some extremely tall window, we have some empty space. So we just hard-code a
// fairly big number.
const SEARCH_RESULTS_FAKE_ROW_COUNT = 99;
type MaybeLoadedSearchResultsType<T> =
| { isLoading: true }
| { isLoading: false; results: Array<T> };
export type LeftPaneSearchPropsType = {
conversationResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
contactResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
messageResults: MaybeLoadedSearchResultsType<{
id: string;
conversationId: string;
type: string;
}>;
searchConversationName?: string;
primarySendsSms: boolean;
searchTerm: string;
startSearchCounter: number;
searchDisabled: boolean;
searchConversation: undefined | ConversationType;
};
const searchResultKeys: Array<
'conversationResults' | 'contactResults' | 'messageResults'
> = ['conversationResults', 'contactResults', 'messageResults'];
export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType> {
private readonly conversationResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
private readonly contactResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
private readonly messageResults: MaybeLoadedSearchResultsType<{
id: string;
conversationId: string;
type: string;
}>;
private readonly searchConversationName?: string;
private readonly primarySendsSms: boolean;
private readonly searchTerm: string;
private readonly startSearchCounter: number;
private readonly searchDisabled: boolean;
private readonly searchConversation: undefined | ConversationType;
constructor({
contactResults,
conversationResults,
messageResults,
primarySendsSms,
searchConversation,
searchConversationName,
searchDisabled,
searchTerm,
startSearchCounter,
}: Readonly<LeftPaneSearchPropsType>) {
super();
this.contactResults = contactResults;
this.conversationResults = conversationResults;
this.messageResults = messageResults;
this.primarySendsSms = primarySendsSms;
this.searchConversation = searchConversation;
this.searchConversationName = searchConversationName;
this.searchDisabled = searchDisabled;
this.searchTerm = searchTerm;
this.startSearchCounter = startSearchCounter;
this.onEnterKeyDown = this.onEnterKeyDown.bind(this);
}
override getSearchInput({
clearConversationSearch,
clearSearch,
i18n,
showConversation,
updateSearchTerm,
}: Readonly<{
clearConversationSearch: () => unknown;
clearSearch: () => unknown;
i18n: LocalizerType;
showConversation: ShowConversationType;
updateSearchTerm: (searchTerm: string) => unknown;
}>): ReactChild {
return (
<LeftPaneSearchInput
clearConversationSearch={clearConversationSearch}
clearSearch={clearSearch}
disabled={this.searchDisabled}
i18n={i18n}
onEnterKeyDown={this.onEnterKeyDown}
searchConversation={this.searchConversation}
searchTerm={this.searchTerm}
showConversation={showConversation}
startSearchCounter={this.startSearchCounter}
updateSearchTerm={updateSearchTerm}
/>
);
}
override getPreRowsNode({
i18n,
}: Readonly<{
i18n: LocalizerType;
}>): ReactChild | null {
const mightHaveSearchResults = this.allResults().some(
searchResult => searchResult.isLoading || searchResult.results.length
);
if (mightHaveSearchResults) {
return null;
}
const { searchConversationName, primarySendsSms, searchTerm } = this;
let noResults: ReactChild;
if (searchConversationName) {
noResults = (
<Intl
id="icu:noSearchResultsInConversation"
i18n={i18n}
components={{
searchTerm,
conversationName: (
<Emojify key="item-1" text={searchConversationName} />
),
}}
/>
);
} else {
noResults = (
<>
<div>{i18n('noSearchResults', [searchTerm])}</div>
{primarySendsSms && (
<div className="module-left-pane__no-search-results__sms-only">
{i18n('noSearchResults--sms-only')}
</div>
)}
</>
);
}
return !searchConversationName || searchTerm ? (
<div
// We need this for Ctrl-T shortcut cycling through parts of app
tabIndex={-1}
className="module-left-pane__no-search-results"
key={searchTerm}
>
{noResults}
</div>
) : null;
}
getRowCount(): number {
if (this.isLoading()) {
// 1 for the header.
return 1 + SEARCH_RESULTS_FAKE_ROW_COUNT;
}
return this.allResults().reduce(
(result: number, searchResults) =>
result + getRowCountForLoadedSearchResults(searchResults),
0
);
}
// This is currently unimplemented. See DESKTOP-1170.
override getRowIndexToScrollTo(
_selectedConversationId: undefined | string
): undefined | number {
return undefined;
}
getRow(rowIndex: number): undefined | Row {
const { conversationResults, contactResults, messageResults } = this;
if (this.isLoading()) {
if (rowIndex === 0) {
return { type: RowType.SearchResultsLoadingFakeHeader };
}
if (rowIndex + 1 <= SEARCH_RESULTS_FAKE_ROW_COUNT) {
return { type: RowType.SearchResultsLoadingFakeRow };
}
return undefined;
}
const conversationRowCount =
getRowCountForLoadedSearchResults(conversationResults);
const contactRowCount = getRowCountForLoadedSearchResults(contactResults);
const messageRowCount = getRowCountForLoadedSearchResults(messageResults);
if (rowIndex < conversationRowCount) {
if (rowIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'conversationsHeader',
};
}
assertDev(
!conversationResults.isLoading,
"We shouldn't get here with conversation results still loading"
);
const conversation = conversationResults.results[rowIndex - 1];
return conversation
? {
type: RowType.Conversation,
conversation,
}
: undefined;
}
if (rowIndex < conversationRowCount + contactRowCount) {
const localIndex = rowIndex - conversationRowCount;
if (localIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'contactsHeader',
};
}
assertDev(
!contactResults.isLoading,
"We shouldn't get here with contact results still loading"
);
const conversation = contactResults.results[localIndex - 1];
return conversation
? {
type: RowType.Conversation,
conversation,
}
: undefined;
}
if (rowIndex >= conversationRowCount + contactRowCount + messageRowCount) {
return undefined;
}
const localIndex = rowIndex - conversationRowCount - contactRowCount;
if (localIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'messagesHeader',
};
}
assertDev(
!messageResults.isLoading,
"We shouldn't get here with message results still loading"
);
const message = messageResults.results[localIndex - 1];
return message
? {
type: RowType.MessageSearchResult,
messageId: message.id,
}
: undefined;
}
override isScrollable(): boolean {
return !this.isLoading();
}
shouldRecomputeRowHeights(old: Readonly<LeftPaneSearchPropsType>): boolean {
const oldIsLoading = new LeftPaneSearchHelper(old).isLoading();
const newIsLoading = this.isLoading();
if (oldIsLoading && newIsLoading) {
return false;
}
if (oldIsLoading !== newIsLoading) {
return true;
}
return searchResultKeys.some(
key =>
getRowCountForLoadedSearchResults(old[key]) !==
getRowCountForLoadedSearchResults(this[key])
);
}
getConversationAndMessageAtIndex(
conversationIndex: number
): undefined | { conversationId: string; messageId?: string } {
if (conversationIndex < 0) {
return undefined;
}
let pointer = conversationIndex;
for (const list of this.allResults()) {
if (list.isLoading) {
continue;
}
if (pointer < list.results.length) {
const result = list.results[pointer];
return result.type === 'incoming' || result.type === 'outgoing' // message
? {
conversationId: result.conversationId,
messageId: result.id,
}
: { conversationId: result.id };
}
pointer -= list.results.length;
}
return undefined;
}
// This is currently unimplemented. See DESKTOP-1170.
getConversationAndMessageInDirection(
_toFind: Readonly<ToFindType>,
_selectedConversationId: undefined | string,
_selectedMessageId: unknown
): undefined | { conversationId: string } {
return undefined;
}
override onKeyDown(
event: KeyboardEvent,
options: Readonly<{
searchInConversation: (conversationId: string) => unknown;
selectedConversationId: undefined | string;
startSearch: () => unknown;
}>
): void {
handleKeydownForSearch(event, options);
}
private allResults() {
return [this.conversationResults, this.contactResults, this.messageResults];
}
private isLoading(): boolean {
return this.allResults().some(results => results.isLoading);
}
private onEnterKeyDown(
clearSearch: () => unknown,
showConversation: ShowConversationType
): void {
const conversation = this.getConversationAndMessageAtIndex(0);
if (!conversation) {
return;
}
showConversation(conversation);
clearSearch();
}
}
function getRowCountForLoadedSearchResults(
searchResults: Readonly<MaybeLoadedSearchResultsType<unknown>>
): number {
// It's possible to call this helper with invalid results (e.g., ones that are loading).
// We could change the parameter of this function, but that adds a bunch of redundant
// checks that are, in the author's opinion, less clear.
if (searchResults.isLoading) {
assertDev(
false,
'getRowCountForLoadedSearchResults: Expected this to be called with loaded search results. Returning 0'
);
return 0;
}
const resultRows = searchResults.results.length;
const hasHeader = Boolean(resultRows);
return (hasHeader ? 1 : 0) + resultRows;
}