// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; import React, { useCallback } from 'react'; import type { ListRowRenderer } from 'react-virtualized'; import classNames from 'classnames'; import { get, pick } from 'lodash'; import { missingCaseError } from '../util/missingCaseError'; import { assertDev } from '../util/assert'; import type { ParsedE164Type } from '../util/libphonenumberInstance'; 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 { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid'; import type { ShowConversationType } from '../state/ducks/conversations'; import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem'; import type { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; import type { ContactListItemConversationType as ContactListItemPropsType } from './conversationList/ContactListItem'; import type { GroupListItemConversationType } from './conversationList/GroupListItem'; import { ConversationListItem } from './conversationList/ConversationListItem'; import { ContactListItem } from './conversationList/ContactListItem'; import { ContactCheckbox as ContactCheckboxComponent } from './conversationList/ContactCheckbox'; import { PhoneNumberCheckbox as PhoneNumberCheckboxComponent } from './conversationList/PhoneNumberCheckbox'; import { UsernameCheckbox as UsernameCheckboxComponent } from './conversationList/UsernameCheckbox'; 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'; import { GroupListItem } from './conversationList/GroupListItem'; import { ListView } from './ListView'; export enum RowType { ArchiveButton = 'ArchiveButton', Blank = 'Blank', Contact = 'Contact', ContactCheckbox = 'ContactCheckbox', PhoneNumberCheckbox = 'PhoneNumberCheckbox', UsernameCheckbox = 'UsernameCheckbox', Conversation = 'Conversation', CreateNewGroup = 'CreateNewGroup', Header = 'Header', MessageSearchResult = 'MessageSearchResult', SearchResultsLoadingFakeHeader = 'SearchResultsLoadingFakeHeader', SearchResultsLoadingFakeRow = 'SearchResultsLoadingFakeRow', // this could later be expanded to SelectSingleConversation SelectSingleGroup = 'SelectSingleGroup', StartNewConversation = 'StartNewConversation', UsernameSearchResult = '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 PhoneNumberCheckboxRowType = { type: RowType.PhoneNumberCheckbox; phoneNumber: ParsedE164Type; isChecked: boolean; isFetching: boolean; }; type UsernameCheckboxRowType = { type: RowType.UsernameCheckbox; username: string; isChecked: boolean; isFetching: boolean; }; 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 SelectSingleGroupRowType = { type: RowType.SelectSingleGroup; group: GroupListItemConversationType; }; type StartNewConversationRowType = { type: RowType.StartNewConversation; phoneNumber: ParsedE164Type; isFetching: boolean; }; type UsernameRowType = { type: RowType.UsernameSearchResult; username: string; isFetchingUsername: boolean; }; export type Row = | ArchiveButtonRowType | BlankRowType | ContactRowType | ContactCheckboxRowType | PhoneNumberCheckboxRowType | UsernameCheckboxRowType | ConversationRowType | CreateNewGroupRowType | MessageRowType | HeaderRowType | SearchResultsLoadingFakeHeaderType | SearchResultsLoadingFakeRowType | StartNewConversationRowType | SelectSingleGroupRowType | 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; showConversation: ShowConversationType; } & LookupConversationWithoutUuidActionsType; const NORMAL_ROW_HEIGHT = 76; const SELECT_ROW_HEIGHT = 52; const HEADER_ROW_HEIGHT = 40; export function ConversationList({ dimensions, getPreferredBadge, getRow, i18n, onClickArchiveButton, onClickContactCheckbox, onSelectConversation, renderMessageSearchResult, rowCount, scrollBehavior = ScrollBehavior.Default, scrollToRowIndex, scrollable = true, shouldRecomputeRowHeights, showChooseGroupMembers, lookupConversationWithoutUuid, showUserNotFoundModal, setIsFetchingUUID, showConversation, theme, }: PropsType): JSX.Element | null { const calculateRowHeight = useCallback( (index: number): number => { const row = getRow(index); if (!row) { assertDev(false, `Expected a row at index ${index}`); return NORMAL_ROW_HEIGHT; } switch (row.type) { case RowType.Header: case RowType.SearchResultsLoadingFakeHeader: return HEADER_ROW_HEIGHT; case RowType.SelectSingleGroup: return SELECT_ROW_HEIGHT; default: return NORMAL_ROW_HEIGHT; } }, [getRow] ); const renderRow: ListRowRenderer = useCallback( ({ key, index, style }) => { const row = getRow(index); if (!row) { assertDev(false, `Expected a row at index ${index}`); return
; } let result: ReactNode; switch (row.type) { case RowType.ArchiveButton: result = ( ); break; case RowType.Blank: result = undefined; break; case RowType.Contact: { const { isClickable = true } = row; result = ( ); break; } case RowType.ContactCheckbox: result = ( ); break; case RowType.PhoneNumberCheckbox: result = ( onClickContactCheckbox(conversationId, undefined) } isChecked={row.isChecked} isFetching={row.isFetching} i18n={i18n} theme={theme} /> ); break; case RowType.UsernameCheckbox: result = ( onClickContactCheckbox(conversationId, undefined) } isChecked={row.isChecked} isFetching={row.isFetching} i18n={i18n} theme={theme} /> ); break; case RowType.Conversation: { const itemProps = pick(row.conversation, [ 'acceptedMessageRequest', 'avatarPath', 'badges', 'color', 'draftPreview', 'id', 'isMe', 'isSelected', 'isPinned', 'lastMessage', 'lastUpdated', 'markedUnread', 'muteExpiresAt', '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.SelectSingleGroup: result = ( ); break; case RowType.StartNewConversation: result = ( ); break; case RowType.UsernameSearchResult: result = ( ); break; default: throw missingCaseError(row); } return ( {result} ); }, [ getPreferredBadge, getRow, i18n, onClickArchiveButton, onClickContactCheckbox, onSelectConversation, lookupConversationWithoutUuid, showUserNotFoundModal, setIsFetchingUUID, renderMessageSearchResult, showChooseGroupMembers, showConversation, 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 ( ); }