// 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 = | { isLoading: true } | { isLoading: false; results: Array }; export type LeftPaneSearchPropsType = { conversationResults: MaybeLoadedSearchResultsType; contactResults: MaybeLoadedSearchResultsType; 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 { private readonly conversationResults: MaybeLoadedSearchResultsType; private readonly contactResults: MaybeLoadedSearchResultsType; 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) { 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 ( ); } 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 = ( ), }} /> ); } else { noResults = ( <>
{i18n('noSearchResults', [searchTerm])}
{primarySendsSms && (
{i18n('noSearchResults--sms-only')}
)} ); } return !searchConversationName || searchTerm ? (
{noResults}
) : 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): 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, _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> ): 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; }