// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useEffect, useMemo, useState, useRef, useCallback, } from 'react'; import { omit } from 'lodash'; import type { ListRowProps } from 'react-virtualized'; import type { LocalizerType, ThemeType } from '../../../../types/Util'; import { getUsernameFromSearch } from '../../../../types/Username'; import { strictAssert, assertDev } from '../../../../util/assert'; import { refMerger } from '../../../../util/refMerger'; import { useRestoreFocus } from '../../../../hooks/useRestoreFocus'; import { missingCaseError } from '../../../../util/missingCaseError'; import type { LookupConversationWithoutServiceIdActionsType } from '../../../../util/lookupConversationWithoutServiceId'; import { parseAndFormatPhoneNumber } from '../../../../util/libphonenumberInstance'; import type { ParsedE164Type } from '../../../../util/libphonenumberInstance'; import { filterAndSortConversationsByRecent } from '../../../../util/filterAndSortConversations'; import type { ConversationType } from '../../../../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../../../../state/selectors/badges'; import type { UUIDFetchStateKeyType, UUIDFetchStateType, } from '../../../../util/uuidFetchState'; import { isFetchingByE164, isFetchingByUsername, } from '../../../../util/uuidFetchState'; import { ModalHost } from '../../../ModalHost'; import { ContactPills } from '../../../ContactPills'; import { ContactPill } from '../../../ContactPill'; import type { Row } from '../../../ConversationList'; import { RowType } from '../../../ConversationList'; import { ContactCheckbox, ContactCheckboxDisabledReason, } from '../../../conversationList/ContactCheckbox'; import { Button, ButtonVariant } from '../../../Button'; import { SearchInput } from '../../../SearchInput'; import { ListView } from '../../../ListView'; import { UsernameCheckbox } from '../../../conversationList/UsernameCheckbox'; import { PhoneNumberCheckbox } from '../../../conversationList/PhoneNumberCheckbox'; import { SizeObserver } from '../../../../hooks/useSizeObserver'; export type StatePropsType = { regionCode: string | undefined; candidateContacts: ReadonlyArray; conversationIdsAlreadyInGroup: Set; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; theme: ThemeType; maxGroupSize: number; searchTerm: string; selectedContacts: ReadonlyArray; confirmAdds: () => void; onClose: () => void; removeSelectedContact: (_: string) => void; setSearchTerm: (_: string) => void; toggleSelectedContact: (conversationId: string) => void; isUsernamesEnabled: boolean; } & Pick< LookupConversationWithoutServiceIdActionsType, 'lookupConversationWithoutServiceId' >; type ActionPropsType = Omit< LookupConversationWithoutServiceIdActionsType, 'setIsFetchingUUID' | 'lookupConversationWithoutServiceId' >; type PropsType = StatePropsType & ActionPropsType; // TODO: This should use . See DESKTOP-1038. export function ChooseGroupMembersModal({ regionCode, candidateContacts, confirmAdds, conversationIdsAlreadyInGroup, i18n, maxGroupSize, onClose, removeSelectedContact, searchTerm, selectedContacts, setSearchTerm, theme, toggleSelectedContact, lookupConversationWithoutServiceId, showUserNotFoundModal, isUsernamesEnabled, }: PropsType): JSX.Element { const [focusRef] = useRestoreFocus(); const parsedUsername = getUsernameFromSearch(searchTerm); let username: string | undefined; let isUsernameChecked = false; let isUsernameVisible = false; if (isUsernamesEnabled) { username = parsedUsername; isUsernameChecked = selectedContacts.some( contact => contact.username === username ); isUsernameVisible = Boolean(username) && candidateContacts.every(contact => contact.username !== username); } let phoneNumber: ParsedE164Type | undefined; if (!parsedUsername) { phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode); } let isPhoneNumberChecked = false; let isPhoneNumberVisible = false; if (phoneNumber) { const { e164 } = phoneNumber; isPhoneNumberChecked = phoneNumber.isValid && selectedContacts.some(contact => contact.e164 === e164); isPhoneNumberVisible = candidateContacts.every( contact => contact.e164 !== e164 ); } const inputRef = useRef(null); const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size; const hasSelectedMaximumNumberOfContacts = selectedContacts.length + numberOfContactsAlreadyInGroup >= maxGroupSize; const selectedConversationIdsSet: Set = useMemo( () => new Set(selectedContacts.map(contact => contact.id)), [selectedContacts] ); const canContinue = Boolean(selectedContacts.length); const [filteredContacts, setFilteredContacts] = useState( filterAndSortConversationsByRecent(candidateContacts, '', regionCode) ); const normalizedSearchTerm = searchTerm.trim(); useEffect(() => { const timeout = setTimeout(() => { setFilteredContacts( filterAndSortConversationsByRecent( candidateContacts, normalizedSearchTerm, regionCode ) ); }, 200); return () => { clearTimeout(timeout); }; }, [ candidateContacts, normalizedSearchTerm, setFilteredContacts, regionCode, ]); const [uuidFetchState, setUuidFetchState] = useState({}); const setIsFetchingUUID = useCallback( (identifier: UUIDFetchStateKeyType, isFetching: boolean) => { setUuidFetchState(prevState => { return isFetching ? { ...prevState, [identifier]: isFetching, } : omit(prevState, identifier); }); }, [setUuidFetchState] ); let rowCount = 0; if (filteredContacts.length) { rowCount += filteredContacts.length; } if (isPhoneNumberVisible || isUsernameVisible) { // "Contacts" header if (filteredContacts.length) { rowCount += 1; } // "Find by phone number" + phone number // or // "Find by username" + username rowCount += 2; } const getRow = (index: number): undefined | Row => { let virtualIndex = index; if ( (isPhoneNumberVisible || isUsernameVisible) && filteredContacts.length ) { if (virtualIndex === 0) { return { type: RowType.Header, // eslint-disable-next-line @typescript-eslint/no-shadow getHeaderText: i18n => i18n('icu:contactsHeader'), }; } virtualIndex -= 1; } if (virtualIndex < filteredContacts.length) { const contact = filteredContacts[virtualIndex]; const isSelected = selectedConversationIdsSet.has(contact.id); const isAlreadyInGroup = conversationIdsAlreadyInGroup.has(contact.id); let disabledReason: undefined | ContactCheckboxDisabledReason; if (isAlreadyInGroup) { disabledReason = ContactCheckboxDisabledReason.AlreadyAdded; } else if (hasSelectedMaximumNumberOfContacts && !isSelected) { disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected; } return { type: RowType.ContactCheckbox, contact, isChecked: isSelected || isAlreadyInGroup, disabledReason, }; } virtualIndex -= filteredContacts.length; if (isPhoneNumberVisible) { strictAssert( phoneNumber !== undefined, "phone number can't be visible if not present" ); if (virtualIndex === 0) { return { type: RowType.Header, // eslint-disable-next-line @typescript-eslint/no-shadow getHeaderText: i18n => i18n('icu:findByPhoneNumberHeader'), }; } if (virtualIndex === 1) { return { type: RowType.PhoneNumberCheckbox, isChecked: isPhoneNumberChecked, isFetching: isFetchingByE164(uuidFetchState, phoneNumber.e164), phoneNumber, }; } virtualIndex -= 2; } if (username) { if (virtualIndex === 0) { return { type: RowType.Header, // eslint-disable-next-line @typescript-eslint/no-shadow getHeaderText: i18n => i18n('icu:findByUsernameHeader'), }; } if (virtualIndex === 1) { return { type: RowType.UsernameCheckbox, isChecked: isUsernameChecked, isFetching: isFetchingByUsername(uuidFetchState, username), username, }; } virtualIndex -= 2; } return undefined; }; const handleContactClick = ( conversationId: string, disabledReason: undefined | ContactCheckboxDisabledReason ) => { switch (disabledReason) { case undefined: toggleSelectedContact(conversationId); break; case ContactCheckboxDisabledReason.AlreadyAdded: case ContactCheckboxDisabledReason.MaximumContactsSelected: // These are no-ops. break; default: throw missingCaseError(disabledReason); } }; const renderItem = ({ key, index, style }: ListRowProps) => { const row = getRow(index); let item; switch (row?.type) { case RowType.Header: { const headerText = row.getHeaderText(i18n); item = (
{headerText}
); break; } case RowType.ContactCheckbox: item = ( ); break; case RowType.UsernameCheckbox: item = ( handleContactClick(conversationId, undefined) } showUserNotFoundModal={showUserNotFoundModal} setIsFetchingUUID={setIsFetchingUUID} lookupConversationWithoutServiceId={ lookupConversationWithoutServiceId } /> ); break; case RowType.PhoneNumberCheckbox: item = ( handleContactClick(conversationId, undefined) } isChecked={row.isChecked} isFetching={row.isFetching} i18n={i18n} theme={theme} /> ); break; default: } return (
{item}
); }; return (
); }