import React from 'react'; import { CellMeasurer, CellMeasurerCache, List } from 'react-virtualized'; import { debounce, get, isNumber } from 'lodash'; import { Intl } from './Intl'; import { Emojify } from './conversation/Emojify'; import { Spinner } from './Spinner'; import { ConversationListItem, PropsData as ConversationListItemPropsType, } from './ConversationListItem'; import { StartNewConversation } from './StartNewConversation'; import { cleanId } from './_util'; import { LocalizerType } from '../types/Util'; export type PropsDataType = { discussionsLoading: boolean; items: Array; messagesLoading: boolean; noResults: boolean; regionCode: string; searchConversationName?: string; searchTerm: string; selectedConversationId?: string; selectedMessageId?: string; }; type StartNewConversationType = { type: 'start-new-conversation'; data: undefined; }; type NotSupportedSMS = { type: 'sms-mms-not-supported-text'; data: undefined; }; type ConversationHeaderType = { type: 'conversations-header'; data: undefined; }; type ContactsHeaderType = { type: 'contacts-header'; data: undefined; }; type MessagesHeaderType = { type: 'messages-header'; data: undefined; }; type ConversationType = { type: 'conversation'; data: ConversationListItemPropsType; }; type ContactsType = { type: 'contact'; data: ConversationListItemPropsType; }; type MessageType = { type: 'message'; data: string; }; type SpinnerType = { type: 'spinner'; data: undefined; }; export type SearchResultRowType = | StartNewConversationType | NotSupportedSMS | ConversationHeaderType | ContactsHeaderType | MessagesHeaderType | ConversationType | ContactsType | MessageType | SpinnerType; type PropsHousekeepingType = { i18n: LocalizerType; openConversationInternal: (id: string, messageId?: string) => void; startNewConversation: ( query: string, options: { regionCode: string } ) => void; height: number; width: number; renderMessageSearchResult: (id: string) => JSX.Element; }; type PropsType = PropsDataType & PropsHousekeepingType; type StateType = { scrollToIndex?: number; }; // from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 type RowRendererParamsType = { index: number; isScrolling: boolean; isVisible: boolean; key: string; parent: Object; style: Object; }; type OnScrollParamsType = { scrollTop: number; clientHeight: number; scrollHeight: number; clientWidth: number; scrollWidth?: number; scrollLeft?: number; scrollToColumn?: number; _hasScrolledToColumnTarget?: boolean; scrollToRow?: number; _hasScrolledToRowTarget?: boolean; }; export class SearchResults extends React.Component { public setFocusToFirstNeeded = false; public setFocusToLastNeeded = false; public cellSizeCache = new CellMeasurerCache({ defaultHeight: 80, fixedWidth: true, }); public listRef = React.createRef(); public containerRef = React.createRef(); public state = { scrollToIndex: undefined, }; public handleStartNewConversation = () => { const { regionCode, searchTerm, startNewConversation } = this.props; startNewConversation(searchTerm, { regionCode }); }; public handleKeyDown = (event: React.KeyboardEvent) => { const { items } = this.props; const commandKey = get(window, 'platform') === 'darwin' && event.metaKey; const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey; const commandOrCtrl = commandKey || controlKey; if (!items || items.length < 1) { return; } if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowUp') { this.setState({ scrollToIndex: 0 }); this.setFocusToFirstNeeded = true; event.preventDefault(); event.stopPropagation(); return; } if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowDown') { const lastIndex = items.length - 1; this.setState({ scrollToIndex: lastIndex }); this.setFocusToLastNeeded = true; event.preventDefault(); event.stopPropagation(); return; } }; public handleFocus = () => { const { selectedConversationId, selectedMessageId } = this.props; const { current: container } = this.containerRef; if (!container) { return; } if (document.activeElement === container) { const scrollingContainer = this.getScrollContainer(); // First we try to scroll to the selected message if (selectedMessageId && scrollingContainer) { // tslint:disable-next-line no-unnecessary-type-assertion const target = scrollingContainer.querySelector( `.module-message-search-result[data-id="${selectedMessageId}"]` ) as any; if (target && target.focus) { target.focus(); return; } } // Then we try for the selected conversation if (selectedConversationId && scrollingContainer) { const escapedId = cleanId(selectedConversationId).replace( /["\\]/g, '\\$&' ); // tslint:disable-next-line no-unnecessary-type-assertion const target = scrollingContainer.querySelector( `.module-conversation-list-item[data-id="${escapedId}"]` ) as any; if (target && target.focus) { target.focus(); return; } } // Otherwise we set focus to the first non-header item this.setFocusToFirst(); } }; public setFocusToFirst = () => { const { current: container } = this.containerRef; if (container) { // tslint:disable-next-line no-unnecessary-type-assertion const noResultsItem = container.querySelector( '.module-search-results__no-results' ) as any; if (noResultsItem && noResultsItem.focus) { noResultsItem.focus(); return; } } const scrollContainer = this.getScrollContainer(); if (!scrollContainer) { return; } // tslint:disable-next-line no-unnecessary-type-assertion const startItem = scrollContainer.querySelector( '.module-start-new-conversation' ) as any; if (startItem && startItem.focus) { startItem.focus(); return; } // tslint:disable-next-line no-unnecessary-type-assertion const conversationItem = scrollContainer.querySelector( '.module-conversation-list-item' ) as any; if (conversationItem && conversationItem.focus) { conversationItem.focus(); return; } // tslint:disable-next-line no-unnecessary-type-assertion const messageItem = scrollContainer.querySelector( '.module-message-search-result' ) as any; if (messageItem && messageItem.focus) { messageItem.focus(); return; } }; public getScrollContainer = () => { if (!this.listRef || !this.listRef.current) { return; } const list = this.listRef.current; if (!list.Grid || !list.Grid._scrollingContainer) { return; } return list.Grid._scrollingContainer as HTMLDivElement; }; // tslint:disable-next-line member-ordering public onScroll = debounce( // tslint:disable-next-line cyclomatic-complexity (data: OnScrollParamsType) => { // Ignore scroll events generated as react-virtualized recursively scrolls and // re-measures to get us where we want to go. if ( isNumber(data.scrollToRow) && data.scrollToRow >= 0 && !data._hasScrolledToRowTarget ) { return; } this.setState({ scrollToIndex: undefined }); if (this.setFocusToFirstNeeded) { this.setFocusToFirstNeeded = false; this.setFocusToFirst(); } if (this.setFocusToLastNeeded) { this.setFocusToLastNeeded = false; const scrollContainer = this.getScrollContainer(); if (!scrollContainer) { return; } const messageItems = scrollContainer.querySelectorAll( '.module-message-search-result' ) as any; if (messageItems && messageItems.length > 0) { const last = messageItems[messageItems.length - 1]; if (last && last.focus) { last.focus(); return; } } const contactItems = scrollContainer.querySelectorAll( '.module-conversation-list-item' ) as any; if (contactItems && contactItems.length > 0) { const last = contactItems[contactItems.length - 1]; if (last && last.focus) { last.focus(); return; } } const startItem = scrollContainer.querySelectorAll( '.module-start-new-conversation' ) as any; if (startItem && startItem.length > 0) { const last = startItem[startItem.length - 1]; if (last && last.focus) { last.focus(); return; } } } }, 100, { maxWait: 100 } ); public renderRowContents(row: SearchResultRowType) { const { searchTerm, i18n, openConversationInternal, renderMessageSearchResult, } = this.props; if (row.type === 'start-new-conversation') { return ( ); } else if (row.type === 'sms-mms-not-supported-text') { return (
{i18n('notSupportedSMS')}
); } else if (row.type === 'conversations-header') { return (
{i18n('conversationsHeader')}
); } else if (row.type === 'conversation') { const { data } = row; return ( ); } else if (row.type === 'contacts-header') { return (
{i18n('contactsHeader')}
); } else if (row.type === 'contact') { const { data } = row; return ( ); } else if (row.type === 'messages-header') { return (
{i18n('messagesHeader')}
); } else if (row.type === 'message') { const { data } = row; return renderMessageSearchResult(data); } else if (row.type === 'spinner') { return (
); } else { throw new Error( 'SearchResults.renderRowContents: Encountered unknown row type' ); } } public renderRow = ({ index, key, parent, style, }: RowRendererParamsType): JSX.Element => { const { items, width } = this.props; const row = items[index]; return (
{this.renderRowContents(row)}
); }; public componentDidUpdate(prevProps: PropsType) { const { items, searchTerm, discussionsLoading, messagesLoading, } = this.props; if (searchTerm !== prevProps.searchTerm) { this.resizeAll(); } else if ( discussionsLoading !== prevProps.discussionsLoading || messagesLoading !== prevProps.messagesLoading ) { this.resizeAll(); } else if ( items && prevProps.items && prevProps.items.length !== items.length ) { this.resizeAll(); } } public getList = () => { if (!this.listRef) { return; } const { current } = this.listRef; return current; }; public recomputeRowHeights = (row?: number) => { const list = this.getList(); if (!list) { return; } list.recomputeRowHeights(row); }; public resizeAll = () => { this.cellSizeCache.clearAll(); this.recomputeRowHeights(0); }; public getRowCount() { const { items } = this.props; return items ? items.length : 0; } public render() { const { height, i18n, items, noResults, searchConversationName, searchTerm, width, } = this.props; const { scrollToIndex } = this.state; if (noResults) { return (
{!searchConversationName || searchTerm ? (
{searchConversationName ? ( ), }} /> ) : ( i18n('noSearchResults', [searchTerm]) )}
) : null}
); } return (
); } }