signal-desktop/ts/components/ConversationList.tsx

370 lines
10 KiB
TypeScript
Raw Normal View History

// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
2021-08-11 16:23:21 +00:00
import React, { useRef, useEffect, useCallback, ReactNode } from 'react';
import { List, ListRowRenderer } from 'react-virtualized';
import classNames from 'classnames';
import { get, pick } from 'lodash';
import { missingCaseError } from '../util/missingCaseError';
import { assert } from '../util/assert';
import { LocalizerType, ScrollBehavior } from '../types/Util';
import {
ConversationListItem,
PropsData as ConversationListItemPropsType,
} from './conversationList/ConversationListItem';
import {
ContactListItem,
PropsDataType as ContactListItemPropsType,
} from './conversationList/ContactListItem';
2021-03-03 20:09:58 +00:00
import {
ContactCheckbox as ContactCheckboxComponent,
ContactCheckboxDisabledReason,
} from './conversationList/ContactCheckbox';
import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton';
import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation';
import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader';
import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } from './conversationList/SearchResultsLoadingFakeRow';
export enum RowType {
ArchiveButton,
2021-03-03 20:09:58 +00:00
Blank,
Contact,
2021-03-03 20:09:58 +00:00
ContactCheckbox,
Conversation,
2021-03-03 20:09:58 +00:00
CreateNewGroup,
Header,
MessageSearchResult,
SearchResultsLoadingFakeHeader,
SearchResultsLoadingFakeRow,
StartNewConversation,
}
type ArchiveButtonRowType = {
type: RowType.ArchiveButton;
archivedConversationsCount: number;
};
2021-03-03 20:09:58 +00:00
type BlankRowType = { type: RowType.Blank };
type ContactRowType = {
type: RowType.Contact;
contact: ContactListItemPropsType;
2021-03-03 20:09:58 +00:00
isClickable?: boolean;
};
type ContactCheckboxRowType = {
type: RowType.ContactCheckbox;
contact: ContactListItemPropsType;
isChecked: boolean;
disabledReason?: ContactCheckboxDisabledReason;
};
type ConversationRowType = {
type: RowType.Conversation;
conversation: ConversationListItemPropsType;
};
2021-03-03 20:09:58 +00:00
type CreateNewGroupRowType = {
type: RowType.CreateNewGroup;
};
type MessageRowType = {
type: RowType.MessageSearchResult;
messageId: string;
};
type HeaderRowType = {
type: RowType.Header;
i18nKey: string;
};
type SearchResultsLoadingFakeHeaderType = {
type: RowType.SearchResultsLoadingFakeHeader;
};
type SearchResultsLoadingFakeRowType = {
type: RowType.SearchResultsLoadingFakeRow;
};
type StartNewConversationRowType = {
type: RowType.StartNewConversation;
phoneNumber: string;
};
export type Row =
| ArchiveButtonRowType
2021-03-03 20:09:58 +00:00
| BlankRowType
| ContactRowType
2021-03-03 20:09:58 +00:00
| ContactCheckboxRowType
| ConversationRowType
2021-03-03 20:09:58 +00:00
| CreateNewGroupRowType
| MessageRowType
| HeaderRowType
| SearchResultsLoadingFakeHeaderType
| SearchResultsLoadingFakeRowType
| StartNewConversationRowType;
export type PropsType = {
dimensions?: {
width: number;
height: number;
};
rowCount: number;
// If `getRow` is called with an invalid index, it should return `undefined`. However,
// this should only happen if there is a bug somewhere. For example, an inaccurate
// `rowCount`.
getRow: (index: number) => undefined | Row;
scrollBehavior?: ScrollBehavior;
scrollToRowIndex?: number;
shouldRecomputeRowHeights: boolean;
scrollable?: boolean;
i18n: LocalizerType;
onClickArchiveButton: () => void;
2021-03-03 20:09:58 +00:00
onClickContactCheckbox: (
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => void;
onSelectConversation: (conversationId: string, messageId?: string) => void;
2021-08-11 16:23:21 +00:00
renderMessageSearchResult: (id: string) => JSX.Element;
2021-03-03 20:09:58 +00:00
showChooseGroupMembers: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
};
const NORMAL_ROW_HEIGHT = 68;
const HEADER_ROW_HEIGHT = 40;
export const ConversationList: React.FC<PropsType> = ({
dimensions,
getRow,
i18n,
onClickArchiveButton,
2021-03-03 20:09:58 +00:00
onClickContactCheckbox,
onSelectConversation,
renderMessageSearchResult,
rowCount,
scrollBehavior = ScrollBehavior.Default,
scrollToRowIndex,
scrollable = true,
shouldRecomputeRowHeights,
2021-03-03 20:09:58 +00:00
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
}) => {
const listRef = useRef<null | List>(null);
useEffect(() => {
const list = listRef.current;
if (shouldRecomputeRowHeights && list) {
list.recomputeRowHeights();
}
});
const calculateRowHeight = useCallback(
({ index }: { index: number }): number => {
const row = getRow(index);
if (!row) {
assert(false, `Expected a row at index ${index}`);
return NORMAL_ROW_HEIGHT;
}
switch (row.type) {
case RowType.Header:
case RowType.SearchResultsLoadingFakeHeader:
return HEADER_ROW_HEIGHT;
default:
return NORMAL_ROW_HEIGHT;
}
},
[getRow]
);
const renderRow: ListRowRenderer = useCallback(
({ key, index, style }) => {
const row = getRow(index);
if (!row) {
assert(false, `Expected a row at index ${index}`);
return <div key={key} style={style} />;
}
2021-08-11 16:23:21 +00:00
let result: ReactNode;
switch (row.type) {
case RowType.ArchiveButton:
2021-08-11 16:23:21 +00:00
result = (
<button
className="module-conversation-list__item--archive-button"
onClick={onClickArchiveButton}
type="button"
>
<div className="module-conversation-list__item--archive-button__icon" />
<span className="module-conversation-list__item--archive-button__text">
{i18n('archivedConversations')}
</span>
<span className="module-conversation-list__item--archive-button__archived-count">
{row.archivedConversationsCount}
</span>
</button>
);
2021-08-11 16:23:21 +00:00
break;
2021-03-03 20:09:58 +00:00
case RowType.Blank:
2021-08-11 16:23:21 +00:00
result = <></>;
break;
2021-03-03 20:09:58 +00:00
case RowType.Contact: {
const { isClickable = true } = row;
2021-08-11 16:23:21 +00:00
result = (
<ContactListItem
{...row.contact}
2021-03-03 20:09:58 +00:00
onClick={isClickable ? onSelectConversation : undefined}
i18n={i18n}
/>
);
2021-08-11 16:23:21 +00:00
break;
2021-03-03 20:09:58 +00:00
}
case RowType.ContactCheckbox:
2021-08-11 16:23:21 +00:00
result = (
2021-03-03 20:09:58 +00:00
<ContactCheckboxComponent
{...row.contact}
isChecked={row.isChecked}
disabledReason={row.disabledReason}
onClick={onClickContactCheckbox}
i18n={i18n}
/>
);
2021-08-11 16:23:21 +00:00
break;
case RowType.Conversation: {
const itemProps = pick(row.conversation, [
'acceptedMessageRequest',
'avatarPath',
'color',
'draftPreview',
'id',
'isMe',
'isSelected',
'lastMessage',
'lastUpdated',
'markedUnread',
'muteExpiresAt',
'name',
'phoneNumber',
'profileName',
'sharedGroupNames',
'shouldShowDraft',
'title',
'type',
'typingContact',
'unblurredAvatarPath',
'unreadCount',
]);
const { title, unreadCount, lastMessage } = itemProps;
2021-08-11 16:23:21 +00:00
result = (
<div
aria-label={i18n('ConversationList__aria-label', {
lastMessage:
get(lastMessage, 'text') ||
i18n('ConversationList__last-message-undefined'),
title,
unreadCount: String(unreadCount),
})}
>
<ConversationListItem
{...itemProps}
key={key}
onClick={onSelectConversation}
i18n={i18n}
/>
</div>
);
2021-08-11 16:23:21 +00:00
break;
}
2021-03-03 20:09:58 +00:00
case RowType.CreateNewGroup:
2021-08-11 16:23:21 +00:00
result = (
2021-03-03 20:09:58 +00:00
<CreateNewGroupButton
i18n={i18n}
onClick={showChooseGroupMembers}
/>
);
2021-08-11 16:23:21 +00:00
break;
case RowType.Header:
2021-08-11 16:23:21 +00:00
result = (
<div
className="module-conversation-list__item--header"
aria-label={i18n(row.i18nKey)}
>
{i18n(row.i18nKey)}
</div>
);
2021-08-11 16:23:21 +00:00
break;
case RowType.MessageSearchResult:
2021-08-11 16:23:21 +00:00
result = <>{renderMessageSearchResult(row.messageId)}</>;
break;
case RowType.SearchResultsLoadingFakeHeader:
2021-08-11 16:23:21 +00:00
result = <SearchResultsLoadingFakeHeaderComponent />;
break;
case RowType.SearchResultsLoadingFakeRow:
2021-08-11 16:23:21 +00:00
result = <SearchResultsLoadingFakeRowComponent />;
break;
case RowType.StartNewConversation:
2021-08-11 16:23:21 +00:00
result = (
<StartNewConversationComponent
i18n={i18n}
phoneNumber={row.phoneNumber}
2021-08-11 16:23:21 +00:00
onClick={startNewConversationFromPhoneNumber}
/>
);
2021-08-11 16:23:21 +00:00
break;
default:
throw missingCaseError(row);
}
2021-08-11 16:23:21 +00:00
return (
<span aria-rowindex={index + 1} role="row" style={style} key={key}>
<div role="gridcell" aria-colindex={1}>
{result}
</div>
2021-08-11 16:23:21 +00:00
</span>
);
},
[
getRow,
i18n,
onClickArchiveButton,
2021-03-03 20:09:58 +00:00
onClickContactCheckbox,
onSelectConversation,
renderMessageSearchResult,
2021-03-03 20:09:58 +00:00
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
]
);
// Though `width` and `height` are required properties, we want to be careful in case
// the caller sends bogus data. Notably, react-measure's types seem to be inaccurate.
const { width = 0, height = 0 } = dimensions || {};
if (!width || !height) {
return null;
}
return (
<List
className={classNames(
'module-conversation-list',
`module-conversation-list--scroll-behavior-${scrollBehavior}`
)}
height={height}
ref={listRef}
rowCount={rowCount}
rowHeight={calculateRowHeight}
rowRenderer={renderRow}
scrollToIndex={scrollToRowIndex}
style={{
// See `<Timeline>` for an explanation of this `any` cast.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
overflowY: scrollable ? ('overlay' as any) : 'hidden',
}}
tabIndex={-1}
width={width}
/>
);
};