// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { FunctionComponent } from 'react'; import React, { useMemo, useReducer } from 'react'; import { without } from 'lodash'; import type { LocalizerType, ThemeType } from '../../../types/Util'; import { AddGroupMemberErrorDialog, AddGroupMemberErrorDialogMode, } from '../../AddGroupMemberErrorDialog'; import type { ConversationType } from '../../../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges'; import { getGroupSizeRecommendedLimit, getGroupSizeHardLimit, } from '../../../groups/limits'; import { toggleSelectedContactForGroupAddition, OneTimeModalState, } from '../../../groups/toggleSelectedContactForGroupAddition'; import { makeLookup } from '../../../util/makeLookup'; import { deconstructLookup } from '../../../util/deconstructLookup'; import { missingCaseError } from '../../../util/missingCaseError'; import type { RequestState } from './util'; import { ChooseGroupMembersModal } from './AddGroupMembersModal/ChooseGroupMembersModal'; import { ConfirmAdditionsModal } from './AddGroupMembersModal/ConfirmAdditionsModal'; type PropsType = { candidateContacts: ReadonlyArray; clearRequestError: () => void; conversationIdsAlreadyInGroup: Set; getPreferredBadge: PreferredBadgeSelectorType; groupTitle: string; i18n: LocalizerType; makeRequest: (conversationIds: ReadonlyArray) => Promise; onClose: () => void; requestState: RequestState; theme: ThemeType; }; enum Stage { ChoosingContacts, ConfirmingAdds, } type StateType = { maximumGroupSizeModalState: OneTimeModalState; recommendedGroupSizeModalState: OneTimeModalState; searchTerm: string; selectedConversationIds: Array; stage: Stage; }; enum ActionType { CloseMaximumGroupSizeModal, CloseRecommendedMaximumGroupSizeModal, ConfirmAdds, RemoveSelectedContact, ReturnToContactChooser, ToggleSelectedContact, UpdateSearchTerm, } type Action = | { type: ActionType.CloseMaximumGroupSizeModal } | { type: ActionType.CloseRecommendedMaximumGroupSizeModal } | { type: ActionType.ConfirmAdds } | { type: ActionType.ReturnToContactChooser } | { type: ActionType.RemoveSelectedContact; conversationId: string } | { type: ActionType.ToggleSelectedContact; conversationId: string; numberOfContactsAlreadyInGroup: number; } | { type: ActionType.UpdateSearchTerm; searchTerm: string }; // `` isn't currently hooked up to Redux, but that's not desirable in // the long term (see DESKTOP-1260). For now, this component has internal state with a // reducer. Hopefully, this will make things easier to port to Redux in the future. function reducer( state: Readonly, action: Readonly ): StateType { switch (action.type) { case ActionType.CloseMaximumGroupSizeModal: return { ...state, maximumGroupSizeModalState: OneTimeModalState.Shown, }; case ActionType.CloseRecommendedMaximumGroupSizeModal: return { ...state, recommendedGroupSizeModalState: OneTimeModalState.Shown, }; case ActionType.ConfirmAdds: return { ...state, stage: Stage.ConfirmingAdds, }; case ActionType.ReturnToContactChooser: return { ...state, stage: Stage.ChoosingContacts, }; case ActionType.RemoveSelectedContact: return { ...state, selectedConversationIds: without( state.selectedConversationIds, action.conversationId ), }; case ActionType.ToggleSelectedContact: return { ...state, ...toggleSelectedContactForGroupAddition(action.conversationId, { maxGroupSize: getMaximumNumberOfContacts(), maxRecommendedGroupSize: getRecommendedMaximumNumberOfContacts(), maximumGroupSizeModalState: state.maximumGroupSizeModalState, numberOfContactsAlreadyInGroup: action.numberOfContactsAlreadyInGroup, recommendedGroupSizeModalState: state.recommendedGroupSizeModalState, selectedConversationIds: state.selectedConversationIds, }), }; case ActionType.UpdateSearchTerm: return { ...state, searchTerm: action.searchTerm, }; default: throw missingCaseError(action); } } export const AddGroupMembersModal: FunctionComponent = ({ candidateContacts, clearRequestError, conversationIdsAlreadyInGroup, getPreferredBadge, groupTitle, i18n, onClose, makeRequest, requestState, theme, }) => { const maxGroupSize = getMaximumNumberOfContacts(); const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts(); const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size; const isGroupAlreadyFull = numberOfContactsAlreadyInGroup >= maxGroupSize; const isGroupAlreadyOverRecommendedMaximum = numberOfContactsAlreadyInGroup >= maxRecommendedGroupSize; const [ { maximumGroupSizeModalState, recommendedGroupSizeModalState, searchTerm, selectedConversationIds, stage, }, dispatch, ] = useReducer(reducer, { maximumGroupSizeModalState: isGroupAlreadyFull ? OneTimeModalState.Showing : OneTimeModalState.NeverShown, recommendedGroupSizeModalState: isGroupAlreadyOverRecommendedMaximum ? OneTimeModalState.Shown : OneTimeModalState.NeverShown, searchTerm: '', selectedConversationIds: [], stage: Stage.ChoosingContacts, }); const contactLookup = useMemo( () => makeLookup(candidateContacts, 'id'), [candidateContacts] ); const selectedContacts = deconstructLookup( contactLookup, selectedConversationIds ); if (maximumGroupSizeModalState === OneTimeModalState.Showing) { return ( { dispatch({ type: ActionType.CloseMaximumGroupSizeModal }); }} /> ); } if (recommendedGroupSizeModalState === OneTimeModalState.Showing) { return ( { dispatch({ type: ActionType.CloseRecommendedMaximumGroupSizeModal, }); }} recommendedMaximumNumberOfContacts={maxRecommendedGroupSize} /> ); } switch (stage) { case Stage.ChoosingContacts: { // See note above: these will soon become Redux actions. const confirmAdds = () => { dispatch({ type: ActionType.ConfirmAdds }); }; const removeSelectedContact = (conversationId: string) => { dispatch({ type: ActionType.RemoveSelectedContact, conversationId, }); }; const setSearchTerm = (term: string) => { dispatch({ type: ActionType.UpdateSearchTerm, searchTerm: term, }); }; const toggleSelectedContact = (conversationId: string) => { dispatch({ type: ActionType.ToggleSelectedContact, conversationId, numberOfContactsAlreadyInGroup, }); }; return ( ); } case Stage.ConfirmingAdds: { const onCloseConfirmationDialog = () => { dispatch({ type: ActionType.ReturnToContactChooser }); clearRequestError(); }; return ( { makeRequest(selectedConversationIds); }} onClose={onCloseConfirmationDialog} requestState={requestState} selectedContacts={selectedContacts} /> ); } default: throw missingCaseError(stage); } }; function getRecommendedMaximumNumberOfContacts(): number { return getGroupSizeRecommendedLimit(151); } function getMaximumNumberOfContacts(): number { return getGroupSizeHardLimit(1001); }