signal-desktop/ts/components/leftPane/LeftPaneSearchHelper.tsx

459 lines
14 KiB
TypeScript
Raw Normal View History

2023-01-03 11:55:46 -08:00
// Copyright 2021 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';
2021-11-01 13:43:02 -05:00
import { handleKeydownForSearch } from './handleKeydownForSearch';
import type {
ConversationType,
2022-06-16 15:12:50 -04:00
ShowConversationType,
} from '../../state/ducks/conversations';
2022-02-14 12:57:11 -05:00
import { LeftPaneSearchInput } from '../LeftPaneSearchInput';
2024-05-15 14:48:02 -07:00
import { I18n } from '../I18n';
import { assertDev } from '../../util/assert';
2023-04-20 10:03:43 -07:00
import { UserText } from '../UserText';
// 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 = {
2021-04-26 11:38:50 -05:00
conversationResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
contactResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
messageResults: MaybeLoadedSearchResultsType<{
id: string;
conversationId: string;
type: string;
}>;
searchConversationName?: string;
searchTerm: string;
2024-11-13 13:33:41 -06:00
filterByUnread: boolean;
2022-01-27 17:12:26 -05:00
startSearchCounter: number;
isSearchingGlobally: boolean;
2022-01-27 17:12:26 -05:00
searchDisabled: boolean;
searchConversation: undefined | ConversationType;
};
2021-04-26 11:38:50 -05:00
export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType> {
readonly #conversationResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
readonly #contactResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
readonly #isSearchingGlobally: boolean;
readonly #messageResults: MaybeLoadedSearchResultsType<{
id: string;
conversationId: string;
type: string;
}>;
readonly #searchConversationName?: string;
readonly #searchTerm: string;
readonly #startSearchCounter: number;
readonly #searchDisabled: boolean;
readonly #searchConversation: undefined | ConversationType;
readonly #filterByUnread: boolean;
2024-11-13 13:33:41 -06:00
constructor({
contactResults,
2022-01-27 17:12:26 -05:00
conversationResults,
isSearchingGlobally,
messageResults,
2022-01-27 17:12:26 -05:00
searchConversation,
searchConversationName,
searchDisabled,
searchTerm,
2022-01-27 17:12:26 -05:00
startSearchCounter,
2024-11-13 13:33:41 -06:00
filterByUnread,
}: Readonly<LeftPaneSearchPropsType>) {
super();
this.#contactResults = contactResults;
this.#conversationResults = conversationResults;
this.#isSearchingGlobally = isSearchingGlobally;
this.#messageResults = messageResults;
this.#searchConversation = searchConversation;
this.#searchConversationName = searchConversationName;
this.#searchDisabled = searchDisabled;
this.#searchTerm = searchTerm;
this.#startSearchCounter = startSearchCounter;
this.#filterByUnread = filterByUnread;
2022-01-27 17:12:26 -05:00
}
override getSearchInput({
clearConversationSearch,
2024-11-13 13:33:41 -06:00
clearSearchQuery,
endConversationSearch,
endSearch,
2022-01-27 17:12:26 -05:00
i18n,
2022-06-16 15:12:50 -04:00
showConversation,
2022-01-27 17:12:26 -05:00
updateSearchTerm,
2024-11-13 13:33:41 -06:00
updateFilterByUnread,
2022-01-27 17:12:26 -05:00
}: Readonly<{
clearConversationSearch: () => unknown;
2024-11-13 13:33:41 -06:00
clearSearchQuery: () => unknown;
endConversationSearch: () => unknown;
endSearch: () => unknown;
2022-01-27 17:12:26 -05:00
i18n: LocalizerType;
2022-06-16 15:12:50 -04:00
showConversation: ShowConversationType;
2022-01-27 17:12:26 -05:00
updateSearchTerm: (searchTerm: string) => unknown;
2024-11-13 13:33:41 -06:00
updateFilterByUnread: (filterByUnread: boolean) => void;
2022-01-27 17:12:26 -05:00
}>): ReactChild {
return (
2022-02-14 12:57:11 -05:00
<LeftPaneSearchInput
2022-01-27 17:12:26 -05:00
clearConversationSearch={clearConversationSearch}
2024-11-13 13:33:41 -06:00
clearSearchQuery={clearSearchQuery}
endConversationSearch={endConversationSearch}
endSearch={endSearch}
disabled={this.#searchDisabled}
2022-01-27 17:12:26 -05:00
i18n={i18n}
isSearchingGlobally={this.#isSearchingGlobally}
onEnterKeyDown={this.#onEnterKeyDown}
searchConversation={this.#searchConversation}
searchTerm={this.#searchTerm}
2022-06-16 15:12:50 -04:00
showConversation={showConversation}
startSearchCounter={this.#startSearchCounter}
2022-01-27 17:12:26 -05:00
updateSearchTerm={updateSearchTerm}
filterButtonEnabled={!this.#searchConversation}
filterPressed={this.#filterByUnread}
2024-11-13 13:33:41 -06:00
onFilterClick={updateFilterByUnread}
2022-01-27 17:12:26 -05:00
/>
);
}
override getPreRowsNode({
i18n,
2022-01-27 17:12:26 -05:00
}: Readonly<{
i18n: LocalizerType;
}>): ReactChild | null {
const mightHaveSearchResults = this.#allResults().some(
searchResult => searchResult.isLoading || searchResult.results.length
);
2022-01-27 17:12:26 -05:00
if (mightHaveSearchResults) {
return null;
}
const searchTerm = this.#searchTerm;
const searchConversationName = this.#searchConversationName;
let noResults: ReactChild;
if (searchConversationName) {
noResults = (
2024-05-15 14:48:02 -07:00
<I18n
id="icu:noSearchResultsInConversation"
i18n={i18n}
components={{
searchTerm,
conversationName: (
2023-04-20 10:03:43 -07:00
<UserText key="item-1" text={searchConversationName} />
),
}}
/>
);
} else {
2024-11-13 13:33:41 -06:00
let noResultsMessage: string;
if (this.#filterByUnread && this.#searchTerm.length > 0) {
2024-11-13 13:33:41 -06:00
noResultsMessage = i18n('icu:noSearchResultsWithUnreadFilter', {
searchTerm,
});
} else if (this.#filterByUnread) {
2024-11-13 13:33:41 -06:00
noResultsMessage = i18n('icu:noSearchResultsOnlyUnreadFilter');
} else {
noResultsMessage = i18n('icu:noSearchResults', {
searchTerm,
});
}
noResults = (
<>
{this.#filterByUnread && (
2024-11-13 13:33:41 -06:00
<div
className="module-conversation-list__item--header module-left-pane__no-search-results__unread-header"
aria-label={i18n('icu:conversationsUnreadHeader')}
>
{i18n('icu:conversationsUnreadHeader')}
</div>
)}
<div>{noResultsMessage}</div>
</>
);
}
return !searchConversationName || searchTerm ? (
<div
// We need this for Ctrl-T shortcut cycling through parts of app
tabIndex={-1}
2024-11-13 13:33:41 -06:00
className={
this.#filterByUnread
2024-11-13 13:33:41 -06:00
? 'module-left-pane__no-search-results--withHeader'
: '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;
}
let count = this.#allResults().reduce(
(result: number, searchResults) =>
result + getRowCountForLoadedSearchResults(searchResults),
0
);
2024-11-13 13:33:41 -06:00
// The clear unread filter button adds an extra row
if (this.#filterByUnread) {
2024-11-13 13:33:41 -06:00
count += 1;
}
return count;
}
// This is currently unimplemented. See DESKTOP-1170.
override getRowIndexToScrollTo(
_selectedConversationId: undefined | string
): undefined | number {
return undefined;
}
getRow(rowIndex: number): undefined | Row {
const messageResults = this.#messageResults;
const contactResults = this.#contactResults;
const conversationResults = this.#conversationResults;
if (this.#isLoading()) {
if (rowIndex === 0) {
return { type: RowType.SearchResultsLoadingFakeHeader };
}
if (rowIndex + 1 <= SEARCH_RESULTS_FAKE_ROW_COUNT) {
return { type: RowType.SearchResultsLoadingFakeRow };
}
return undefined;
}
2021-11-11 16:43:05 -06:00
const conversationRowCount =
getRowCountForLoadedSearchResults(conversationResults);
const contactRowCount = getRowCountForLoadedSearchResults(contactResults);
const messageRowCount = getRowCountForLoadedSearchResults(messageResults);
const clearFilterButtonRowCount = this.#filterByUnread ? 1 : 0;
2024-11-13 13:33:41 -06:00
let rowOffset = 0;
2024-11-13 13:33:41 -06:00
rowOffset += conversationRowCount;
if (rowIndex < rowOffset) {
if (rowIndex === 0) {
return {
type: RowType.Header,
2024-11-13 13:33:41 -06:00
getHeaderText: i18n =>
this.#filterByUnread
2024-11-13 13:33:41 -06:00
? i18n('icu:conversationsUnreadHeader')
: i18n('icu: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;
}
2024-11-13 13:33:41 -06:00
rowOffset += contactRowCount;
if (rowIndex < rowOffset) {
const localIndex = rowIndex - conversationRowCount;
if (localIndex === 0) {
return {
type: RowType.Header,
2023-03-29 17:03:25 -07:00
getHeaderText: i18n => i18n('icu: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;
}
2024-11-13 13:33:41 -06:00
rowOffset += messageRowCount;
if (rowIndex < rowOffset) {
const localIndex = rowIndex - conversationRowCount - contactRowCount;
if (localIndex === 0) {
return {
type: RowType.Header,
getHeaderText: i18n => i18n('icu: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;
}
2024-11-13 13:33:41 -06:00
rowOffset += clearFilterButtonRowCount;
if (rowIndex < rowOffset) {
return {
2024-11-13 13:33:41 -06:00
type: RowType.ClearFilterButton,
isOnNoResultsPage: this.#allResults().every(
2024-11-13 13:33:41 -06:00
searchResult =>
searchResult.isLoading || searchResult.results.length === 0
),
};
}
2024-11-13 13:33:41 -06:00
return undefined;
}
override isScrollable(): boolean {
return !this.#isLoading();
}
shouldRecomputeRowHeights(old: Readonly<LeftPaneSearchPropsType>): boolean {
2024-11-13 13:33:41 -06:00
const oldSearchPaneHelper = new LeftPaneSearchHelper(old);
const oldIsLoading = oldSearchPaneHelper.#isLoading();
const newIsLoading = this.#isLoading();
if (oldIsLoading && newIsLoading) {
return false;
}
if (oldIsLoading !== newIsLoading) {
return true;
}
const searchResultsByKey = [
{ current: this.#conversationResults, prev: old.conversationResults },
{ current: this.#contactResults, prev: old.contactResults },
{ current: this.#messageResults, prev: old.messageResults },
];
return searchResultsByKey.some(item => {
return (
getRowCountForLoadedSearchResults(item.prev) !==
getRowCountForLoadedSearchResults(item.current)
);
});
}
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,
2023-03-20 15:23:53 -07:00
_targetedMessageId: unknown
): undefined | { conversationId: string } {
return undefined;
}
override onKeyDown(
2021-11-01 13:43:02 -05:00
event: KeyboardEvent,
options: Readonly<{
searchInConversation: (conversationId: string) => unknown;
selectedConversationId: undefined | string;
startSearch: () => unknown;
}>
): void {
handleKeydownForSearch(event, options);
}
#allResults() {
return [
this.#conversationResults,
this.#contactResults,
this.#messageResults,
];
}
#isLoading(): boolean {
return this.#allResults().some(results => results.isLoading);
}
#onEnterKeyDown = (
2024-11-13 13:33:41 -06:00
clearSearchQuery: () => unknown,
2022-06-16 15:12:50 -04:00
showConversation: ShowConversationType
): void => {
const conversation = this.getConversationAndMessageAtIndex(0);
if (!conversation) {
return;
}
2022-06-16 15:12:50 -04:00
showConversation(conversation);
2024-11-13 13:33:41 -06:00
clearSearchQuery();
};
}
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;
}