// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; import React, { useRef, useEffect, useCallback } from 'react'; import type { ListRowRenderer } from 'react-virtualized'; import { List } from 'react-virtualized'; import classNames from 'classnames'; import { get, pick } from 'lodash'; import { missingCaseError } from '../util/missingCaseError'; import { assert } from '../util/assert'; import type { LocalizerType, ThemeType } from '../types/Util'; import { ScrollBehavior } from '../types/Util'; import { getConversationListWidthBreakpoint } from './_util'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem'; import { ConversationListItem } from './conversationList/ConversationListItem'; import type { ContactListItemConversationType as ContactListItemPropsType } from './conversationList/ContactListItem'; import { ContactListItem } from './conversationList/ContactListItem'; import type { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; import { ContactCheckbox as ContactCheckboxComponent } 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'; import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem'; export enum RowType { ArchiveButton, Blank, Contact, ContactCheckbox, Conversation, CreateNewGroup, Header, MessageSearchResult, SearchResultsLoadingFakeHeader, SearchResultsLoadingFakeRow, StartNewConversation, UsernameSearchResult, } type ArchiveButtonRowType = { type: RowType.ArchiveButton; archivedConversationsCount: number; }; type BlankRowType = { type: RowType.Blank }; type ContactRowType = { type: RowType.Contact; contact: ContactListItemPropsType; isClickable?: boolean; }; type ContactCheckboxRowType = { type: RowType.ContactCheckbox; contact: ContactListItemPropsType; isChecked: boolean; disabledReason?: ContactCheckboxDisabledReason; }; type ConversationRowType = { type: RowType.Conversation; conversation: ConversationListItemPropsType; }; 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; }; type UsernameRowType = { type: RowType.UsernameSearchResult; username: string; isFetchingUsername: boolean; }; export type Row = | ArchiveButtonRowType | BlankRowType | ContactRowType | ContactCheckboxRowType | ConversationRowType | CreateNewGroupRowType | MessageRowType | HeaderRowType | SearchResultsLoadingFakeHeaderType | SearchResultsLoadingFakeRowType | StartNewConversationRowType | UsernameRowType; 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; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; theme: ThemeType; onClickArchiveButton: () => void; onClickContactCheckbox: ( conversationId: string, disabledReason: undefined | ContactCheckboxDisabledReason ) => void; onSelectConversation: (conversationId: string, messageId?: string) => void; renderMessageSearchResult: (id: string) => JSX.Element; showChooseGroupMembers: () => void; startNewConversationFromPhoneNumber: (e164: string) => void; startNewConversationFromUsername: (username: string) => void; }; const NORMAL_ROW_HEIGHT = 76; const HEADER_ROW_HEIGHT = 40; export const ConversationList: React.FC = ({ dimensions, getPreferredBadge, getRow, i18n, onClickArchiveButton, onClickContactCheckbox, onSelectConversation, renderMessageSearchResult, rowCount, scrollBehavior = ScrollBehavior.Default, scrollToRowIndex, scrollable = true, shouldRecomputeRowHeights, showChooseGroupMembers, startNewConversationFromPhoneNumber, startNewConversationFromUsername, theme, }) => { const listRef = useRef(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
; } let result: ReactNode; switch (row.type) { case RowType.ArchiveButton: result = ( ); break; case RowType.Blank: result = <>; break; case RowType.Contact: { const { isClickable = true } = row; result = ( ); break; } case RowType.ContactCheckbox: result = ( ); break; case RowType.Conversation: { const itemProps = pick(row.conversation, [ 'acceptedMessageRequest', 'avatarPath', 'badges', 'color', 'draftPreview', 'id', 'isMe', 'isSelected', 'isPinned', 'lastMessage', 'lastUpdated', 'markedUnread', 'muteExpiresAt', 'name', 'phoneNumber', 'profileName', 'sharedGroupNames', 'shouldShowDraft', 'title', 'type', 'typingContactId', 'unblurredAvatarPath', 'unreadCount', ]); const { badges, title, unreadCount, lastMessage } = itemProps; result = (
); break; } case RowType.CreateNewGroup: result = ( ); break; case RowType.Header: result = (
{i18n(row.i18nKey)}
); break; case RowType.MessageSearchResult: result = <>{renderMessageSearchResult(row.messageId)}; break; case RowType.SearchResultsLoadingFakeHeader: result = ; break; case RowType.SearchResultsLoadingFakeRow: result = ; break; case RowType.StartNewConversation: result = ( ); break; case RowType.UsernameSearchResult: result = ( ); break; default: throw missingCaseError(row); } return ( {result} ); }, [ getPreferredBadge, getRow, i18n, onClickArchiveButton, onClickContactCheckbox, onSelectConversation, renderMessageSearchResult, showChooseGroupMembers, startNewConversationFromPhoneNumber, startNewConversationFromUsername, theme, ] ); // 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; } const widthBreakpoint = getConversationListWidthBreakpoint(width); return ( ` 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} /> ); };