Add "new conversation" composer for direct messages
This commit is contained in:
parent
84dc166b63
commit
06fb4fd0bc
61 changed files with 5960 additions and 3887 deletions
|
@ -1,649 +1,327 @@
|
|||
// Copyright 2019-2020 Signal Messenger, LLC
|
||||
// Copyright 2019-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Measure, { BoundingRect, MeasuredComponentProps } from 'react-measure';
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { List } from 'react-virtualized';
|
||||
import { debounce, get } from 'lodash';
|
||||
import React, { useRef, useEffect, useMemo, CSSProperties } from 'react';
|
||||
import Measure, { MeasuredComponentProps } from 'react-measure';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
import {
|
||||
ConversationListItem,
|
||||
PropsData as ConversationListItemPropsType,
|
||||
} from './ConversationListItem';
|
||||
LeftPaneHelper,
|
||||
FindDirection,
|
||||
ToFindType,
|
||||
} from './leftPane/LeftPaneHelper';
|
||||
import {
|
||||
PropsDataType as SearchResultsProps,
|
||||
SearchResults,
|
||||
} from './SearchResults';
|
||||
LeftPaneInboxHelper,
|
||||
LeftPaneInboxPropsType,
|
||||
} from './leftPane/LeftPaneInboxHelper';
|
||||
import {
|
||||
LeftPaneSearchHelper,
|
||||
LeftPaneSearchPropsType,
|
||||
} from './leftPane/LeftPaneSearchHelper';
|
||||
import {
|
||||
LeftPaneArchiveHelper,
|
||||
LeftPaneArchivePropsType,
|
||||
} from './leftPane/LeftPaneArchiveHelper';
|
||||
import {
|
||||
LeftPaneComposeHelper,
|
||||
LeftPaneComposePropsType,
|
||||
} from './leftPane/LeftPaneComposeHelper';
|
||||
|
||||
import * as OS from '../OS';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { cleanId } from './_util';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
import { ConversationList } from './ConversationList';
|
||||
|
||||
export enum LeftPaneMode {
|
||||
Inbox,
|
||||
Search,
|
||||
Archive,
|
||||
Compose,
|
||||
}
|
||||
|
||||
export type PropsType = {
|
||||
conversations?: Array<ConversationListItemPropsType>;
|
||||
archivedConversations?: Array<ConversationListItemPropsType>;
|
||||
pinnedConversations?: Array<ConversationListItemPropsType>;
|
||||
selectedConversationId?: string;
|
||||
searchResults?: SearchResultsProps;
|
||||
showArchived?: boolean;
|
||||
|
||||
// These help prevent invalid states. For example, we don't need the list of pinned
|
||||
// conversations if we're trying to start a new conversation. Ideally these would be
|
||||
// at the top level, but this is not supported by react-redux + TypeScript.
|
||||
modeSpecificProps:
|
||||
| ({
|
||||
mode: LeftPaneMode.Inbox;
|
||||
} & LeftPaneInboxPropsType)
|
||||
| ({
|
||||
mode: LeftPaneMode.Search;
|
||||
} & LeftPaneSearchPropsType)
|
||||
| ({
|
||||
mode: LeftPaneMode.Archive;
|
||||
} & LeftPaneArchivePropsType)
|
||||
| ({
|
||||
mode: LeftPaneMode.Compose;
|
||||
} & LeftPaneComposePropsType);
|
||||
i18n: LocalizerType;
|
||||
selectedConversationId: undefined | string;
|
||||
selectedMessageId: undefined | string;
|
||||
regionCode: string;
|
||||
|
||||
// Action Creators
|
||||
startNewConversation: (
|
||||
query: string,
|
||||
options: { regionCode: string }
|
||||
) => void;
|
||||
openConversationInternal: (id: string, messageId?: string) => void;
|
||||
startNewConversationFromPhoneNumber: (e164: string) => void;
|
||||
openConversationInternal: (_: {
|
||||
conversationId: string;
|
||||
messageId?: string;
|
||||
switchToAssociatedView?: boolean;
|
||||
}) => void;
|
||||
showArchivedConversations: () => void;
|
||||
showInbox: () => void;
|
||||
startComposing: () => void;
|
||||
setComposeSearchTerm: (composeSearchTerm: string) => void;
|
||||
|
||||
// Render Props
|
||||
renderExpiredBuildDialog: () => JSX.Element;
|
||||
renderMainHeader: () => JSX.Element;
|
||||
renderMessageSearchResult: (id: string) => JSX.Element;
|
||||
renderMessageSearchResult: (id: string, style: CSSProperties) => 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: Record<string, unknown>;
|
||||
style: CSSProperties;
|
||||
};
|
||||
export const LeftPane: React.FC<PropsType> = ({
|
||||
i18n,
|
||||
modeSpecificProps,
|
||||
openConversationInternal,
|
||||
renderExpiredBuildDialog,
|
||||
renderMainHeader,
|
||||
renderMessageSearchResult,
|
||||
renderNetworkStatus,
|
||||
renderRelinkDialog,
|
||||
renderUpdateDialog,
|
||||
selectedConversationId,
|
||||
selectedMessageId,
|
||||
setComposeSearchTerm,
|
||||
showArchivedConversations,
|
||||
showInbox,
|
||||
startComposing,
|
||||
startNewConversationFromPhoneNumber,
|
||||
}) => {
|
||||
const previousModeSpecificPropsRef = useRef(modeSpecificProps);
|
||||
const previousModeSpecificProps = previousModeSpecificPropsRef.current;
|
||||
previousModeSpecificPropsRef.current = modeSpecificProps;
|
||||
|
||||
export enum RowType {
|
||||
ArchiveButton,
|
||||
ArchivedConversation,
|
||||
Conversation,
|
||||
Header,
|
||||
PinnedConversation,
|
||||
Undefined,
|
||||
}
|
||||
|
||||
export enum HeaderType {
|
||||
Pinned,
|
||||
Chats,
|
||||
}
|
||||
|
||||
type ArchiveButtonRow = {
|
||||
type: RowType.ArchiveButton;
|
||||
};
|
||||
|
||||
type ConversationRow = {
|
||||
index: number;
|
||||
type:
|
||||
| RowType.ArchivedConversation
|
||||
| RowType.Conversation
|
||||
| RowType.PinnedConversation;
|
||||
};
|
||||
|
||||
type HeaderRow = {
|
||||
headerType: HeaderType;
|
||||
type: RowType.Header;
|
||||
};
|
||||
|
||||
type UndefinedRow = {
|
||||
type: RowType.Undefined;
|
||||
};
|
||||
|
||||
type Row = ArchiveButtonRow | ConversationRow | HeaderRow | UndefinedRow;
|
||||
|
||||
export class LeftPane extends React.Component<PropsType> {
|
||||
public listRef = React.createRef<List>();
|
||||
|
||||
public containerRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
public setFocusToFirstNeeded = false;
|
||||
|
||||
public setFocusToLastNeeded = false;
|
||||
|
||||
public calculateRowHeight = ({ index }: { index: number }): number => {
|
||||
const { type } = this.getRowFromIndex(index);
|
||||
return type === RowType.Header ? 40 : 68;
|
||||
};
|
||||
|
||||
public getRowFromIndex = (index: number): Row => {
|
||||
const {
|
||||
archivedConversations,
|
||||
conversations,
|
||||
pinnedConversations,
|
||||
showArchived,
|
||||
} = this.props;
|
||||
|
||||
if (!conversations || !pinnedConversations || !archivedConversations) {
|
||||
return {
|
||||
type: RowType.Undefined,
|
||||
};
|
||||
// The left pane can be in various modes: the inbox, the archive, the composer, etc.
|
||||
// Ideally, this would render subcomponents such as `<LeftPaneInbox>` or
|
||||
// `<LeftPaneArchive>` (and if there's a way to do that cleanly, we should refactor
|
||||
// this).
|
||||
//
|
||||
// But doing that presents two problems:
|
||||
//
|
||||
// 1. Different components render the same logical inputs (the main header's search),
|
||||
// but React doesn't know that they're the same, so you can lose focus as you change
|
||||
// modes.
|
||||
// 2. These components render virtualized lists, which are somewhat slow to initialize.
|
||||
// Switching between modes can cause noticable hiccups.
|
||||
//
|
||||
// To get around those problems, we use "helpers" which all correspond to the same
|
||||
// interface.
|
||||
//
|
||||
// Unfortunately, there's a little bit of repetition here because TypeScript isn't quite
|
||||
// smart enough.
|
||||
let helper: LeftPaneHelper<unknown>;
|
||||
let shouldRecomputeRowHeights: boolean;
|
||||
switch (modeSpecificProps.mode) {
|
||||
case LeftPaneMode.Inbox: {
|
||||
const inboxHelper = new LeftPaneInboxHelper(modeSpecificProps);
|
||||
shouldRecomputeRowHeights =
|
||||
previousModeSpecificProps.mode === modeSpecificProps.mode
|
||||
? inboxHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
|
||||
: true;
|
||||
helper = inboxHelper;
|
||||
break;
|
||||
}
|
||||
|
||||
if (showArchived) {
|
||||
return {
|
||||
index,
|
||||
type: RowType.ArchivedConversation,
|
||||
};
|
||||
case LeftPaneMode.Search: {
|
||||
const searchHelper = new LeftPaneSearchHelper(modeSpecificProps);
|
||||
shouldRecomputeRowHeights =
|
||||
previousModeSpecificProps.mode === modeSpecificProps.mode
|
||||
? searchHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
|
||||
: true;
|
||||
helper = searchHelper;
|
||||
break;
|
||||
}
|
||||
|
||||
let conversationIndex = index;
|
||||
|
||||
if (pinnedConversations.length) {
|
||||
if (conversations.length) {
|
||||
if (index === 0) {
|
||||
return {
|
||||
headerType: HeaderType.Pinned,
|
||||
type: RowType.Header,
|
||||
};
|
||||
}
|
||||
|
||||
if (index <= pinnedConversations.length) {
|
||||
return {
|
||||
index: index - 1,
|
||||
type: RowType.PinnedConversation,
|
||||
};
|
||||
}
|
||||
|
||||
if (index === pinnedConversations.length + 1) {
|
||||
return {
|
||||
headerType: HeaderType.Chats,
|
||||
type: RowType.Header,
|
||||
};
|
||||
}
|
||||
|
||||
conversationIndex -= pinnedConversations.length + 2;
|
||||
} else if (index < pinnedConversations.length) {
|
||||
return {
|
||||
index,
|
||||
type: RowType.PinnedConversation,
|
||||
};
|
||||
} else {
|
||||
conversationIndex = 0;
|
||||
}
|
||||
case LeftPaneMode.Archive: {
|
||||
const archiveHelper = new LeftPaneArchiveHelper(modeSpecificProps);
|
||||
shouldRecomputeRowHeights =
|
||||
previousModeSpecificProps.mode === modeSpecificProps.mode
|
||||
? archiveHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
|
||||
: true;
|
||||
helper = archiveHelper;
|
||||
break;
|
||||
}
|
||||
|
||||
if (conversationIndex === conversations.length) {
|
||||
return {
|
||||
type: RowType.ArchiveButton,
|
||||
};
|
||||
case LeftPaneMode.Compose: {
|
||||
const composeHelper = new LeftPaneComposeHelper(modeSpecificProps);
|
||||
shouldRecomputeRowHeights =
|
||||
previousModeSpecificProps.mode === modeSpecificProps.mode
|
||||
? composeHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
|
||||
: true;
|
||||
helper = composeHelper;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
index: conversationIndex,
|
||||
type: RowType.Conversation,
|
||||
};
|
||||
};
|
||||
|
||||
public renderConversationRow(
|
||||
conversation: ConversationListItemPropsType,
|
||||
key: string,
|
||||
style: CSSProperties
|
||||
): JSX.Element {
|
||||
const { i18n, openConversationInternal } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="module-left-pane__conversation-container"
|
||||
style={style}
|
||||
>
|
||||
<ConversationListItem
|
||||
{...conversation}
|
||||
onClick={openConversationInternal}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
throw missingCaseError(modeSpecificProps);
|
||||
}
|
||||
|
||||
public renderHeaderRow = (
|
||||
index: number,
|
||||
key: string,
|
||||
style: CSSProperties
|
||||
): JSX.Element => {
|
||||
const { i18n } = this.props;
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const { ctrlKey, shiftKey, altKey, metaKey, key } = event;
|
||||
const commandOrCtrl = OS.isMacOS() ? metaKey : ctrlKey;
|
||||
|
||||
switch (index) {
|
||||
case HeaderType.Pinned: {
|
||||
return (
|
||||
<div className="module-left-pane__header-row" key={key} style={style}>
|
||||
{i18n('LeftPane--pinned')}
|
||||
</div>
|
||||
if (
|
||||
commandOrCtrl &&
|
||||
!shiftKey &&
|
||||
!altKey &&
|
||||
(key === 'n' || key === 'N')
|
||||
) {
|
||||
startComposing();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
let conversationToOpen:
|
||||
| undefined
|
||||
| {
|
||||
conversationId: string;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
const numericIndex = keyboardKeyToNumericIndex(event.key);
|
||||
if (commandOrCtrl && isNumber(numericIndex)) {
|
||||
conversationToOpen = helper.getConversationAndMessageAtIndex(
|
||||
numericIndex
|
||||
);
|
||||
}
|
||||
case HeaderType.Chats: {
|
||||
return (
|
||||
<div className="module-left-pane__header-row" key={key} style={style}>
|
||||
{i18n('LeftPane--chats')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
window.log.warn('LeftPane: invalid HeaderRowIndex received');
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public renderRow = ({
|
||||
index,
|
||||
key,
|
||||
style,
|
||||
}: RowRendererParamsType): JSX.Element => {
|
||||
const {
|
||||
archivedConversations,
|
||||
conversations,
|
||||
pinnedConversations,
|
||||
} = this.props;
|
||||
|
||||
if (!conversations || !pinnedConversations || !archivedConversations) {
|
||||
throw new Error(
|
||||
'renderRow: Tried to render without conversations or pinnedConversations or archivedConversations'
|
||||
);
|
||||
}
|
||||
|
||||
const row = this.getRowFromIndex(index);
|
||||
|
||||
switch (row.type) {
|
||||
case RowType.ArchiveButton: {
|
||||
return this.renderArchivedButton(key, style);
|
||||
}
|
||||
case RowType.ArchivedConversation: {
|
||||
return this.renderConversationRow(
|
||||
archivedConversations[row.index],
|
||||
key,
|
||||
style
|
||||
);
|
||||
}
|
||||
case RowType.Conversation: {
|
||||
return this.renderConversationRow(conversations[row.index], key, style);
|
||||
}
|
||||
case RowType.Header: {
|
||||
return this.renderHeaderRow(row.headerType, key, style);
|
||||
}
|
||||
case RowType.PinnedConversation: {
|
||||
return this.renderConversationRow(
|
||||
pinnedConversations[row.index],
|
||||
key,
|
||||
style
|
||||
);
|
||||
}
|
||||
default:
|
||||
window.log.warn('LeftPane: unknown RowType received');
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
public renderArchivedButton = (
|
||||
key: string,
|
||||
style: CSSProperties
|
||||
): JSX.Element => {
|
||||
const {
|
||||
archivedConversations,
|
||||
i18n,
|
||||
showArchivedConversations,
|
||||
} = this.props;
|
||||
|
||||
if (!archivedConversations || !archivedConversations.length) {
|
||||
throw new Error(
|
||||
'renderArchivedButton: Tried to render without archivedConversations'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className="module-left-pane__archived-button"
|
||||
style={style}
|
||||
onClick={showArchivedConversations}
|
||||
type="button"
|
||||
>
|
||||
{i18n('archivedConversations')}{' '}
|
||||
<span className="module-left-pane__archived-button__archived-count">
|
||||
{archivedConversations.length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
public handleFocus = (): void => {
|
||||
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,
|
||||
'\\$&'
|
||||
);
|
||||
const target: HTMLElement | null = scrollingContainer.querySelector(
|
||||
`.module-conversation-list-item[data-id="${escapedId}"]`
|
||||
);
|
||||
|
||||
if (target && target.focus) {
|
||||
target.focus();
|
||||
|
||||
return;
|
||||
} else {
|
||||
let toFind: undefined | ToFindType;
|
||||
if (
|
||||
(altKey && !shiftKey && key === 'ArrowUp') ||
|
||||
(commandOrCtrl && shiftKey && key === '[') ||
|
||||
(ctrlKey && shiftKey && key === 'Tab')
|
||||
) {
|
||||
toFind = { direction: FindDirection.Up, unreadOnly: false };
|
||||
} else if (
|
||||
(altKey && !shiftKey && key === 'ArrowDown') ||
|
||||
(commandOrCtrl && shiftKey && key === ']') ||
|
||||
(ctrlKey && key === 'Tab')
|
||||
) {
|
||||
toFind = { direction: FindDirection.Down, unreadOnly: false };
|
||||
} else if (altKey && shiftKey && key === 'ArrowUp') {
|
||||
toFind = { direction: FindDirection.Up, unreadOnly: true };
|
||||
} else if (altKey && shiftKey && key === 'ArrowDown') {
|
||||
toFind = { direction: FindDirection.Down, unreadOnly: true };
|
||||
}
|
||||
if (toFind) {
|
||||
conversationToOpen = helper.getConversationAndMessageInDirection(
|
||||
toFind,
|
||||
selectedConversationId,
|
||||
selectedMessageId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.setFocusToFirst();
|
||||
}
|
||||
};
|
||||
|
||||
public scrollToRow = (row: number): void => {
|
||||
if (!this.listRef || !this.listRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listRef.current.scrollToRow(row);
|
||||
};
|
||||
|
||||
public recomputeRowHeights = (): void => {
|
||||
if (!this.listRef || !this.listRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listRef.current.recomputeRowHeights();
|
||||
};
|
||||
|
||||
public getScrollContainer = (): HTMLDivElement | null => {
|
||||
if (!this.listRef || !this.listRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const list = this.listRef.current;
|
||||
|
||||
// TODO: DESKTOP-689
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const grid: any = list.Grid;
|
||||
if (!grid || !grid._scrollingContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return grid._scrollingContainer as HTMLDivElement;
|
||||
};
|
||||
|
||||
public setFocusToFirst = (): void => {
|
||||
const scrollContainer = this.getScrollContainer();
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: HTMLElement | null = scrollContainer.querySelector(
|
||||
'.module-conversation-list-item'
|
||||
);
|
||||
if (item && item.focus) {
|
||||
item.focus();
|
||||
}
|
||||
};
|
||||
|
||||
public onScroll = debounce(
|
||||
(): void => {
|
||||
if (this.setFocusToFirstNeeded) {
|
||||
this.setFocusToFirstNeeded = false;
|
||||
this.setFocusToFirst();
|
||||
if (conversationToOpen) {
|
||||
const { conversationId, messageId } = conversationToOpen;
|
||||
openConversationInternal({ conversationId, messageId });
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (this.setFocusToLastNeeded) {
|
||||
this.setFocusToLastNeeded = false;
|
||||
};
|
||||
|
||||
const scrollContainer = this.getScrollContainer();
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [
|
||||
helper,
|
||||
openConversationInternal,
|
||||
selectedConversationId,
|
||||
selectedMessageId,
|
||||
startComposing,
|
||||
]);
|
||||
|
||||
const button: HTMLElement | null = scrollContainer.querySelector(
|
||||
'.module-left-pane__archived-button'
|
||||
);
|
||||
if (button && button.focus) {
|
||||
button.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
const items: NodeListOf<HTMLElement> = scrollContainer.querySelectorAll(
|
||||
'.module-conversation-list-item'
|
||||
);
|
||||
if (items && items.length > 0) {
|
||||
const last = items[items.length - 1];
|
||||
|
||||
if (last && last.focus) {
|
||||
last.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
const preRowsNode = helper.getPreRowsNode({
|
||||
i18n,
|
||||
onChangeComposeSearchTerm: event => {
|
||||
setComposeSearchTerm(event.target.value);
|
||||
},
|
||||
100,
|
||||
{ maxWait: 100 }
|
||||
);
|
||||
});
|
||||
const getRow = useMemo(() => helper.getRow.bind(helper), [helper]);
|
||||
|
||||
public getLength = (): number => {
|
||||
const {
|
||||
archivedConversations,
|
||||
conversations,
|
||||
pinnedConversations,
|
||||
showArchived,
|
||||
} = this.props;
|
||||
// We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring
|
||||
// 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 = preRowsNode ? 1 : 0;
|
||||
|
||||
if (!conversations || !archivedConversations || !pinnedConversations) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (showArchived) {
|
||||
return archivedConversations.length;
|
||||
}
|
||||
|
||||
let { length } = conversations;
|
||||
|
||||
if (pinnedConversations.length) {
|
||||
if (length) {
|
||||
// includes two additional rows for pinned/chats headers
|
||||
length += 2;
|
||||
}
|
||||
length += pinnedConversations.length;
|
||||
}
|
||||
|
||||
// includes one additional row for 'archived conversations' button
|
||||
if (archivedConversations.length) {
|
||||
length += 1;
|
||||
}
|
||||
|
||||
return length;
|
||||
};
|
||||
|
||||
public renderList = ({
|
||||
height,
|
||||
width,
|
||||
}: BoundingRect): JSX.Element | Array<JSX.Element | null> => {
|
||||
const {
|
||||
archivedConversations,
|
||||
i18n,
|
||||
conversations,
|
||||
openConversationInternal,
|
||||
pinnedConversations,
|
||||
renderMessageSearchResult,
|
||||
startNewConversation,
|
||||
searchResults,
|
||||
showArchived,
|
||||
} = this.props;
|
||||
|
||||
if (searchResults) {
|
||||
return (
|
||||
<SearchResults
|
||||
{...searchResults}
|
||||
height={height || 0}
|
||||
width={width || 0}
|
||||
openConversationInternal={openConversationInternal}
|
||||
startNewConversation={startNewConversation}
|
||||
renderMessageSearchResult={renderMessageSearchResult}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!conversations || !archivedConversations || !pinnedConversations) {
|
||||
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 (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="module-left-pane__list"
|
||||
key={listKey}
|
||||
onFocus={this.handleFocus}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
ref={this.containerRef}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<List
|
||||
className="module-left-pane__virtual-list"
|
||||
conversations={conversations}
|
||||
height={height || 0}
|
||||
onScroll={this.onScroll}
|
||||
ref={this.listRef}
|
||||
rowCount={length}
|
||||
rowHeight={this.calculateRowHeight}
|
||||
rowRenderer={this.renderRow}
|
||||
tabIndex={-1}
|
||||
width={width || 0}
|
||||
/>
|
||||
return (
|
||||
<div className="module-left-pane">
|
||||
<div className="module-left-pane__header">
|
||||
{helper.getHeaderContents({ i18n, showInbox }) || renderMainHeader()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
public renderArchivedHeader = (): JSX.Element => {
|
||||
const { i18n, showInbox } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-left-pane__archive-header">
|
||||
<button
|
||||
onClick={showInbox}
|
||||
className="module-left-pane__to-inbox-button"
|
||||
title={i18n('backToInbox')}
|
||||
aria-label={i18n('backToInbox')}
|
||||
type="button"
|
||||
/>
|
||||
<div className="module-left-pane__archive-header-text">
|
||||
{i18n('archivedConversations')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const {
|
||||
i18n,
|
||||
renderExpiredBuildDialog,
|
||||
renderMainHeader,
|
||||
renderNetworkStatus,
|
||||
renderRelinkDialog,
|
||||
renderUpdateDialog,
|
||||
showArchived,
|
||||
} = this.props;
|
||||
|
||||
// Relying on 3rd party code for contentRect.bounds
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
return (
|
||||
<div className="module-left-pane">
|
||||
<div className="module-left-pane__header">
|
||||
{showArchived ? this.renderArchivedHeader() : renderMainHeader()}
|
||||
</div>
|
||||
{renderExpiredBuildDialog()}
|
||||
{renderRelinkDialog()}
|
||||
{renderNetworkStatus()}
|
||||
{renderUpdateDialog()}
|
||||
{showArchived && (
|
||||
<div className="module-left-pane__archive-helper-text" key={0}>
|
||||
{i18n('archiveHelperText')}
|
||||
</div>
|
||||
)}
|
||||
<Measure bounds>
|
||||
{({ contentRect, measureRef }: MeasuredComponentProps) => (
|
||||
<div className="module-left-pane__list--measure" ref={measureRef}>
|
||||
<div className="module-left-pane__list--wrapper">
|
||||
{this.renderList(contentRect.bounds!)}
|
||||
{renderExpiredBuildDialog()}
|
||||
{renderRelinkDialog()}
|
||||
{helper.shouldRenderNetworkStatusAndUpdateDialog() && (
|
||||
<>
|
||||
{renderNetworkStatus()}
|
||||
{renderUpdateDialog()}
|
||||
</>
|
||||
)}
|
||||
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
|
||||
<Measure bounds>
|
||||
{({ contentRect, measureRef }: MeasuredComponentProps) => (
|
||||
<div className="module-left-pane__list--measure" ref={measureRef}>
|
||||
<div className="module-left-pane__list--wrapper">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="module-left-pane__list"
|
||||
key={listKey}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ConversationList
|
||||
dimensions={contentRect.bounds}
|
||||
getRow={getRow}
|
||||
i18n={i18n}
|
||||
onClickArchiveButton={showArchivedConversations}
|
||||
onSelectConversation={(
|
||||
conversationId: string,
|
||||
messageId?: string
|
||||
) => {
|
||||
openConversationInternal({
|
||||
conversationId,
|
||||
messageId,
|
||||
switchToAssociatedView: true,
|
||||
});
|
||||
}}
|
||||
renderMessageSearchResult={renderMessageSearchResult}
|
||||
rowCount={helper.getRowCount()}
|
||||
scrollToRowIndex={helper.getRowIndexToScrollTo(
|
||||
selectedConversationId
|
||||
)}
|
||||
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
|
||||
startNewConversationFromPhoneNumber={
|
||||
startNewConversationFromPhoneNumber
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Measure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate(oldProps: PropsType): void {
|
||||
const {
|
||||
conversations: oldConversations = [],
|
||||
pinnedConversations: oldPinnedConversations = [],
|
||||
archivedConversations: oldArchivedConversations = [],
|
||||
showArchived: oldShowArchived,
|
||||
} = oldProps;
|
||||
const {
|
||||
conversations: newConversations = [],
|
||||
pinnedConversations: newPinnedConversations = [],
|
||||
archivedConversations: newArchivedConversations = [],
|
||||
showArchived: newShowArchived,
|
||||
} = this.props;
|
||||
|
||||
const oldHasArchivedConversations = Boolean(
|
||||
oldArchivedConversations.length
|
||||
);
|
||||
const newHasArchivedConversations = Boolean(
|
||||
newArchivedConversations.length
|
||||
);
|
||||
|
||||
// This could probably be optimized further, but we want to be extra-careful that our
|
||||
// heights are correct.
|
||||
if (
|
||||
oldConversations.length !== newConversations.length ||
|
||||
oldPinnedConversations.length !== newPinnedConversations.length ||
|
||||
oldHasArchivedConversations !== newHasArchivedConversations ||
|
||||
oldShowArchived !== newShowArchived
|
||||
) {
|
||||
this.recomputeRowHeights();
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</Measure>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function keyboardKeyToNumericIndex(key: string): undefined | number {
|
||||
if (key.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const result = parseInt(key, 10) - 1;
|
||||
const isValidIndex = Number.isInteger(result) && result >= 0 && result <= 8;
|
||||
return isValidIndex ? result : undefined;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue