// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactChild, ChangeEvent } from 'react'; import React from 'react'; import { LeftPaneHelper } from './LeftPaneHelper'; import type { Row } from '../ConversationList'; import { RowType } from '../ConversationList'; import type { ConversationType } from '../../state/ducks/conversations'; import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox'; import { ContactPills } from '../ContactPills'; import { ContactPill } from '../ContactPill'; import { SearchInput } from '../SearchInput'; import { AddGroupMemberErrorDialog, AddGroupMemberErrorDialogMode, } from '../AddGroupMemberErrorDialog'; import { Button } from '../Button'; import type { LocalizerType } from '../../types/Util'; import { getUsernameFromSearch } from '../../types/Username'; import type { ParsedE164Type } from '../../util/libphonenumberInstance'; import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance'; import type { UUIDFetchStateType } from '../../util/uuidFetchState'; import { isFetchingByUsername, isFetchingByE164, } from '../../util/uuidFetchState'; export type LeftPaneChooseGroupMembersPropsType = { uuidFetchState: UUIDFetchStateType; candidateContacts: ReadonlyArray; groupSizeRecommendedLimit: number; groupSizeHardLimit: number; isShowingRecommendedGroupSizeModal: boolean; isShowingMaximumGroupSizeModal: boolean; isUsernamesEnabled: boolean; searchTerm: string; regionCode: string | undefined; selectedContacts: Array; }; export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper { private readonly candidateContacts: ReadonlyArray; private readonly isPhoneNumberChecked: boolean; private readonly isUsernameChecked: boolean; private readonly isShowingMaximumGroupSizeModal: boolean; private readonly isShowingRecommendedGroupSizeModal: boolean; private readonly groupSizeRecommendedLimit: number; private readonly groupSizeHardLimit: number; private readonly searchTerm: string; private readonly phoneNumber: ParsedE164Type | undefined; private readonly username: string | undefined; private readonly selectedContacts: Array; private readonly selectedConversationIdsSet: Set; private readonly uuidFetchState: UUIDFetchStateType; constructor({ candidateContacts, isShowingMaximumGroupSizeModal, isShowingRecommendedGroupSizeModal, isUsernamesEnabled, groupSizeRecommendedLimit, groupSizeHardLimit, searchTerm, regionCode, selectedContacts, uuidFetchState, }: Readonly) { super(); this.uuidFetchState = uuidFetchState; this.groupSizeRecommendedLimit = groupSizeRecommendedLimit - 1; this.groupSizeHardLimit = groupSizeHardLimit - 1; this.candidateContacts = candidateContacts; this.isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal; this.isShowingRecommendedGroupSizeModal = isShowingRecommendedGroupSizeModal; this.searchTerm = searchTerm; if (isUsernamesEnabled) { const username = getUsernameFromSearch(searchTerm); const isVisible = this.candidateContacts.every( contact => contact.username !== username ); if (isVisible) { this.username = username; } this.isUsernameChecked = selectedContacts.some( contact => contact.username === this.username ); } else { this.isUsernameChecked = false; } const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode); if (!this.username && phoneNumber) { this.isPhoneNumberChecked = phoneNumber.isValid && selectedContacts.some(contact => contact.e164 === phoneNumber.e164); const isVisible = this.candidateContacts.every( contact => contact.e164 !== phoneNumber.e164 ); if (isVisible) { this.phoneNumber = phoneNumber; } } else { this.isPhoneNumberChecked = false; } this.selectedContacts = selectedContacts; this.selectedConversationIdsSet = new Set( selectedContacts.map(contact => contact.id) ); } override getHeaderContents({ i18n, startComposing, }: Readonly<{ i18n: LocalizerType; startComposing: () => void; }>): ReactChild { const backButtonLabel = i18n('chooseGroupMembers__back-button'); return (
); } override getBackAction({ startComposing, }: { startComposing: () => void; }): () => void { return startComposing; } override getSearchInput({ i18n, onChangeComposeSearchTerm, }: Readonly<{ i18n: LocalizerType; onChangeComposeSearchTerm: ( event: ChangeEvent ) => unknown; }>): ReactChild { return ( ); } override getPreRowsNode({ closeMaximumGroupSizeModal, closeRecommendedGroupSizeModal, i18n, removeSelectedContact, }: Readonly<{ closeMaximumGroupSizeModal: () => unknown; closeRecommendedGroupSizeModal: () => unknown; i18n: LocalizerType; removeSelectedContact: (conversationId: string) => unknown; }>): ReactChild { let modalNode: undefined | ReactChild; if (this.isShowingMaximumGroupSizeModal) { modalNode = ( ); } else if (this.isShowingRecommendedGroupSizeModal) { modalNode = ( ); } return ( <> {Boolean(this.selectedContacts.length) && ( {this.selectedContacts.map(contact => ( ))} )} {this.getRowCount() ? null : (
{i18n('noContactsFound')}
)} {modalNode} ); } override getFooterContents({ i18n, startSettingGroupMetadata, }: Readonly<{ i18n: LocalizerType; startSettingGroupMetadata: () => void; }>): ReactChild { return ( ); } getRowCount(): number { let rowCount = 0; // Header + Phone Number if (this.phoneNumber) { rowCount += 2; } // Header + Username if (this.username) { rowCount += 2; } // Header + Contacts if (this.candidateContacts.length) { rowCount += 1 + this.candidateContacts.length; } // Footer if (rowCount > 0) { rowCount += 1; } return rowCount; } getRow(actualRowIndex: number): undefined | Row { if (!this.candidateContacts.length && !this.phoneNumber && !this.username) { return undefined; } const rowCount = this.getRowCount(); // This puts a blank row for the footer. if (actualRowIndex === rowCount - 1) { return { type: RowType.Blank }; } let virtualRowIndex = actualRowIndex; if (this.candidateContacts.length) { if (virtualRowIndex === 0) { return { type: RowType.Header, i18nKey: 'contactsHeader', }; } if (virtualRowIndex <= this.candidateContacts.length) { const contact = this.candidateContacts[virtualRowIndex - 1]; const isChecked = this.selectedConversationIdsSet.has(contact.id); const disabledReason = !isChecked && this.hasSelectedMaximumNumberOfContacts() ? ContactCheckboxDisabledReason.MaximumContactsSelected : undefined; return { type: RowType.ContactCheckbox, contact, isChecked, disabledReason, }; } virtualRowIndex -= 1 + this.candidateContacts.length; } if (this.phoneNumber) { if (virtualRowIndex === 0) { return { type: RowType.Header, i18nKey: 'findByPhoneNumberHeader', }; } if (virtualRowIndex === 1) { return { type: RowType.PhoneNumberCheckbox, isChecked: this.isPhoneNumberChecked, isFetching: isFetchingByE164( this.uuidFetchState, this.phoneNumber.e164 ), phoneNumber: this.phoneNumber, }; } virtualRowIndex -= 2; } if (this.username) { if (virtualRowIndex === 0) { return { type: RowType.Header, i18nKey: 'findByUsernameHeader', }; } if (virtualRowIndex === 1) { return { type: RowType.UsernameCheckbox, isChecked: this.isUsernameChecked, isFetching: isFetchingByUsername(this.uuidFetchState, this.username), username: this.username, }; } virtualRowIndex -= 2; } return undefined; } // This is deliberately unimplemented because these keyboard shortcuts shouldn't work in // the composer. The same is true for the "in direction" function below. getConversationAndMessageAtIndex( ..._args: ReadonlyArray ): undefined { return undefined; } getConversationAndMessageInDirection( ..._args: ReadonlyArray ): undefined { return undefined; } shouldRecomputeRowHeights(_old: unknown): boolean { return false; } private hasSelectedMaximumNumberOfContacts(): boolean { return this.selectedContacts.length >= this.groupSizeHardLimit; } private hasExceededMaximumNumberOfContacts(): boolean { // It should be impossible to reach this state. This is here as a failsafe. return this.selectedContacts.length > this.groupSizeHardLimit; } } function focusRef(el: HTMLElement | null) { if (el) { el.focus(); } }