signal-desktop/ts/components/SearchResults.tsx

599 lines
15 KiB
TypeScript

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<SearchResultRowType>;
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<PropsType, StateType> {
public setFocusToFirstNeeded = false;
public setFocusToLastNeeded = false;
public cellSizeCache = new CellMeasurerCache({
defaultHeight: 80,
fixedWidth: true,
});
public listRef = React.createRef<any>();
public containerRef = React.createRef<HTMLDivElement>();
public state = {
scrollToIndex: undefined,
};
public handleStartNewConversation = () => {
const { regionCode, searchTerm, startNewConversation } = this.props;
startNewConversation(searchTerm, { regionCode });
};
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
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 (
<StartNewConversation
phoneNumber={searchTerm}
i18n={i18n}
onClick={this.handleStartNewConversation}
/>
);
} else if (row.type === 'sms-mms-not-supported-text') {
return (
<div className="module-search-results__sms-not-supported">
{i18n('notSupportedSMS')}
</div>
);
} else if (row.type === 'conversations-header') {
return (
<div
className="module-search-results__conversations-header"
role="heading"
aria-level={1}
>
{i18n('conversationsHeader')}
</div>
);
} else if (row.type === 'conversation') {
const { data } = row;
return (
<ConversationListItem
key={data.phoneNumber}
{...data}
onClick={openConversationInternal}
i18n={i18n}
/>
);
} else if (row.type === 'contacts-header') {
return (
<div
className="module-search-results__contacts-header"
role="heading"
aria-level={1}
>
{i18n('contactsHeader')}
</div>
);
} else if (row.type === 'contact') {
const { data } = row;
return (
<ConversationListItem
key={data.phoneNumber}
{...data}
onClick={openConversationInternal}
i18n={i18n}
/>
);
} else if (row.type === 'messages-header') {
return (
<div
className="module-search-results__messages-header"
role="heading"
aria-level={1}
>
{i18n('messagesHeader')}
</div>
);
} else if (row.type === 'message') {
const { data } = row;
return renderMessageSearchResult(data);
} else if (row.type === 'spinner') {
return (
<div className="module-search-results__spinner-container">
<Spinner size="24px" svgSize="small" />
</div>
);
} 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 (
<div role="row" key={key} style={style}>
<CellMeasurer
cache={this.cellSizeCache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
width={width}
>
{this.renderRowContents(row)}
</CellMeasurer>
</div>
);
};
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 (
<div
className="module-search-results"
tabIndex={-1}
ref={this.containerRef}
onFocus={this.handleFocus}
>
{!searchConversationName || searchTerm ? (
<div
// We need this for Ctrl-T shortcut cycling through parts of app
tabIndex={-1}
className="module-search-results__no-results"
key={searchTerm}
>
{searchConversationName ? (
<Intl
id="noSearchResultsInConversation"
i18n={i18n}
components={[
searchTerm,
<Emojify key="item-1" text={searchConversationName} />,
]}
/>
) : (
i18n('noSearchResults', [searchTerm])
)}
</div>
) : null}
</div>
);
}
return (
<div
className="module-search-results"
aria-live="polite"
role="group"
tabIndex={-1}
ref={this.containerRef}
onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus}
>
<List
className="module-search-results__virtual-list"
deferredMeasurementCache={this.cellSizeCache}
height={height}
items={items}
overscanRowCount={5}
ref={this.listRef}
rowCount={this.getRowCount()}
rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.renderRow}
scrollToIndex={scrollToIndex}
tabIndex={-1}
onScroll={this.onScroll as any}
width={width}
/>
</div>
);
}
}