import Measure, { BoundingRect, MeasuredComponentProps } from 'react-measure'; import React from 'react'; import { List } from 'react-virtualized'; import { debounce, get } from 'lodash'; import { ConversationListItem, PropsData as ConversationListItemPropsType, } from './ConversationListItem'; import { PropsDataType as SearchResultsProps, SearchResults, } from './SearchResults'; import { LocalizerType } from '../types/Util'; import { cleanId } from './_util'; export interface PropsType { conversations?: Array; archivedConversations?: Array; selectedConversationId?: string; searchResults?: SearchResultsProps; showArchived?: boolean; i18n: LocalizerType; // Action Creators startNewConversation: ( query: string, options: { regionCode: string } ) => void; openConversationInternal: (id: string, messageId?: string) => void; showArchivedConversations: () => void; showInbox: () => void; // Render Props renderExpiredBuildDialog: () => JSX.Element; renderMainHeader: () => JSX.Element; renderMessageSearchResult: (id: string) => JSX.Element; renderNetworkStatus: () => JSX.Element; renderRelinkDialog: () => JSX.Element; renderUpdateDialog: () => JSX.Element; } // 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; }; export class LeftPane extends React.Component { public listRef = React.createRef(); public containerRef = React.createRef(); public setFocusToFirstNeeded = false; public setFocusToLastNeeded = false; public renderRow = ({ index, key, style, }: RowRendererParamsType): JSX.Element => { const { archivedConversations, conversations, i18n, openConversationInternal, showArchived, } = this.props; if (!conversations || !archivedConversations) { throw new Error( 'renderRow: Tried to render without conversations or archivedConversations' ); } if (!showArchived && index === conversations.length) { return this.renderArchivedButton({ key, style }); } const conversation = showArchived ? archivedConversations[index] : conversations[index]; return (
); }; public renderArchivedButton = ({ key, style, }: { key: string; style: Object; }): JSX.Element => { const { archivedConversations, i18n, showArchivedConversations, } = this.props; if (!archivedConversations || !archivedConversations.length) { throw new Error( 'renderArchivedButton: Tried to render without archivedConversations' ); } return ( ); }; public handleKeyDown = (event: React.KeyboardEvent) => { const commandKey = get(window, 'platform') === 'darwin' && event.metaKey; const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey; const commandOrCtrl = commandKey || controlKey; if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowUp') { this.scrollToRow(0); this.setFocusToFirstNeeded = true; event.preventDefault(); event.stopPropagation(); return; } if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowDown') { const length = this.getLength(); this.scrollToRow(length - 1); this.setFocusToLastNeeded = true; event.preventDefault(); event.stopPropagation(); return; } }; public handleFocus = () => { const { selectedConversationId } = this.props; const { current: container } = this.containerRef; if (!container) { return; } if (document.activeElement === container) { const scrollingContainer = this.getScrollContainer(); 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; } } this.setFocusToFirst(); } }; public scrollToRow = (row: number) => { if (!this.listRef || !this.listRef.current) { return; } this.listRef.current.scrollToRow(row); }; 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; }; public setFocusToFirst = () => { const scrollContainer = this.getScrollContainer(); if (!scrollContainer) { return; } // tslint:disable-next-line no-unnecessary-type-assertion const item = scrollContainer.querySelector( '.module-conversation-list-item' ) as any; if (item && item.focus) { item.focus(); return; } }; // tslint:disable-next-line member-ordering public onScroll = debounce( () => { if (this.setFocusToFirstNeeded) { this.setFocusToFirstNeeded = false; this.setFocusToFirst(); } if (this.setFocusToLastNeeded) { this.setFocusToLastNeeded = false; const scrollContainer = this.getScrollContainer(); if (!scrollContainer) { return; } // tslint:disable-next-line no-unnecessary-type-assertion const button = scrollContainer.querySelector( '.module-left-pane__archived-button' ) as any; if (button && button.focus) { button.focus(); return; } // tslint:disable-next-line no-unnecessary-type-assertion const items = scrollContainer.querySelectorAll( '.module-conversation-list-item' ) as any; if (items && items.length > 0) { const last = items[items.length - 1]; if (last && last.focus) { last.focus(); return; } } } }, 100, { maxWait: 100 } ); public getLength = () => { const { archivedConversations, conversations, showArchived } = this.props; if (!conversations || !archivedConversations) { return 0; } // That extra 1 element added to the list is the 'archived conversations' button return showArchived ? archivedConversations.length : conversations.length + (archivedConversations.length ? 1 : 0); }; public renderList = ({ height, width, }: BoundingRect): JSX.Element | Array => { const { archivedConversations, i18n, conversations, openConversationInternal, renderMessageSearchResult, startNewConversation, searchResults, showArchived, } = this.props; if (searchResults) { return ( ); } if (!conversations || !archivedConversations) { throw new Error( 'render: must provided conversations and archivedConverstions if no search results are provided' ); } const length = this.getLength(); // We ensure that the listKey differs between inbox and archive views, which ensures // that AutoSizer properly detects the new size of its slot in the flexbox. The // archive explainer text at the top of the archive view causes problems otherwise. // It also ensures that we scroll to the top when switching views. const listKey = showArchived ? 1 : 0; // Note: conversations is not a known prop for List, but it is required to ensure that // it re-renders when our conversation data changes. Otherwise it would just render // on startup and scroll. return (
); }; public renderArchivedHeader = (): JSX.Element => { const { i18n, showInbox } = this.props; return (
); }; public render(): JSX.Element { const { i18n, renderExpiredBuildDialog, renderMainHeader, renderNetworkStatus, renderRelinkDialog, renderUpdateDialog, showArchived, } = this.props; /* tslint:disable no-non-null-assertion */ return (
{showArchived ? this.renderArchivedHeader() : renderMainHeader()}
{renderExpiredBuildDialog()} {renderRelinkDialog()} {renderNetworkStatus()} {renderUpdateDialog()} {showArchived && (
{i18n('archiveHelperText')}
)} {({ contentRect, measureRef }: MeasuredComponentProps) => (
{this.renderList(contentRect.bounds!)}
)}
); } }