Allow adding to a group by phone number

This commit is contained in:
Fedor Indutny 2022-04-04 17:38:22 -07:00 committed by GitHub
parent 76a1a805ef
commit 9568d5792e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1842 additions and 693 deletions

View file

@ -9,12 +9,18 @@ import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { sleep } from '../../../util/sleep';
import { makeLookup } from '../../../util/makeLookup';
import { deconstructLookup } from '../../../util/deconstructLookup';
import { setupI18n } from '../../../util/setupI18n';
import type { ConversationType } from '../../../state/ducks/conversations';
import enMessages from '../../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { AddGroupMembersModal } from './AddGroupMembersModal';
import { ChooseGroupMembersModal } from './AddGroupMembersModal/ChooseGroupMembersModal';
import { ConfirmAdditionsModal } from './AddGroupMembersModal/ConfirmAdditionsModal';
import { RequestState } from './util';
import { ThemeType } from '../../../types/Util';
import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid';
const i18n = setupI18n('en', enMessages);
@ -24,14 +30,23 @@ const story = storiesOf(
);
const allCandidateContacts = times(50, () => getDefaultConversation());
let allCandidateContactsLookup = makeLookup(allCandidateContacts, 'id');
const lookupConversationWithoutUuid = makeFakeLookupConversationWithoutUuid(
convo => {
allCandidateContacts.push(convo);
allCandidateContactsLookup = makeLookup(allCandidateContacts, 'id');
}
);
type PropsType = ComponentProps<typeof AddGroupMembersModal>;
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
candidateContacts: allCandidateContacts,
const createProps = (
overrideProps: Partial<PropsType> = {},
candidateContacts: Array<ConversationType> = []
): PropsType => ({
clearRequestError: action('clearRequestError'),
conversationIdsAlreadyInGroup: new Set(),
getPreferredBadge: () => undefined,
groupTitle: 'Tahoe Trip',
i18n,
onClose: action('onClose'),
@ -39,7 +54,38 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
action('onMakeRequest')(conversationIds);
},
requestState: RequestState.Inactive,
theme: ThemeType.light,
renderChooseGroupMembersModal: props => {
const { selectedConversationIds } = props;
return (
<ChooseGroupMembersModal
{...props}
candidateContacts={candidateContacts}
selectedContacts={deconstructLookup(
allCandidateContactsLookup,
selectedConversationIds
)}
regionCode="US"
getPreferredBadge={() => undefined}
theme={ThemeType.light}
i18n={i18n}
lookupConversationWithoutUuid={lookupConversationWithoutUuid}
showUserNotFoundModal={action('showUserNotFoundModal')}
/>
);
},
renderConfirmAdditionsModal: props => {
const { selectedConversationIds } = props;
return (
<ConfirmAdditionsModal
{...props}
i18n={i18n}
selectedContacts={deconstructLookup(
allCandidateContactsLookup,
selectedConversationIds
)}
/>
);
},
...overrideProps,
});
@ -47,18 +93,12 @@ story.add('Default', () => <AddGroupMembersModal {...createProps()} />);
story.add('Only 3 contacts', () => (
<AddGroupMembersModal
{...createProps({
candidateContacts: allCandidateContacts.slice(0, 3),
})}
{...createProps({}, allCandidateContacts.slice(0, 3))}
/>
));
story.add('No candidate contacts', () => (
<AddGroupMembersModal
{...createProps({
candidateContacts: [],
})}
/>
<AddGroupMembersModal {...createProps({}, [])} />
));
story.add('Everyone already added', () => (

View file

@ -2,16 +2,16 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react';
import React, { useMemo, useReducer } from 'react';
import React, { useReducer } from 'react';
import { without } from 'lodash';
import type { LocalizerType, ThemeType } from '../../../types/Util';
import type { LocalizerType } from '../../../types/Util';
import {
AddGroupMemberErrorDialog,
AddGroupMemberErrorDialogMode,
} from '../../AddGroupMemberErrorDialog';
import type { ConversationType } from '../../../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges';
import type { SmartChooseGroupMembersModalPropsType } from '../../../state/smart/ChooseGroupMembersModal';
import type { SmartConfirmAdditionsModalPropsType } from '../../../state/smart/ConfirmAdditionsModal';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
@ -20,24 +20,24 @@ 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<ConversationType>;
clearRequestError: () => void;
conversationIdsAlreadyInGroup: Set<string>;
getPreferredBadge: PreferredBadgeSelectorType;
groupTitle: string;
i18n: LocalizerType;
makeRequest: (conversationIds: ReadonlyArray<string>) => Promise<void>;
onClose: () => void;
requestState: RequestState;
theme: ThemeType;
renderChooseGroupMembersModal: (
props: SmartChooseGroupMembersModalPropsType
) => JSX.Element;
renderConfirmAdditionsModal: (
props: SmartConfirmAdditionsModalPropsType
) => JSX.Element;
};
enum Stage {
@ -135,16 +135,15 @@ function reducer(
}
export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
candidateContacts,
clearRequestError,
conversationIdsAlreadyInGroup,
getPreferredBadge,
groupTitle,
i18n,
onClose,
makeRequest,
requestState,
theme,
renderChooseGroupMembersModal,
renderConfirmAdditionsModal,
}) => {
const maxGroupSize = getMaximumNumberOfContacts();
const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts();
@ -175,16 +174,6 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
stage: Stage.ChoosingContacts,
});
const contactLookup = useMemo(
() => makeLookup(candidateContacts, 'id'),
[candidateContacts]
);
const selectedContacts = deconstructLookup(
contactLookup,
selectedConversationIds
);
if (maximumGroupSizeModalState === OneTimeModalState.Showing) {
return (
<AddGroupMemberErrorDialog
@ -239,23 +228,17 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
});
};
return (
<ChooseGroupMembersModal
candidateContacts={candidateContacts}
confirmAdds={confirmAdds}
conversationIdsAlreadyInGroup={conversationIdsAlreadyInGroup}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
maxGroupSize={maxGroupSize}
onClose={onClose}
removeSelectedContact={removeSelectedContact}
searchTerm={searchTerm}
selectedContacts={selectedContacts}
setSearchTerm={setSearchTerm}
theme={theme}
toggleSelectedContact={toggleSelectedContact}
/>
);
return renderChooseGroupMembersModal({
confirmAdds,
selectedConversationIds,
conversationIdsAlreadyInGroup,
maxGroupSize,
onClose,
removeSelectedContact,
searchTerm,
setSearchTerm,
toggleSelectedContact,
});
}
case Stage.ConfirmingAdds: {
const onCloseConfirmationDialog = () => {
@ -263,18 +246,15 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
clearRequestError();
};
return (
<ConfirmAdditionsModal
groupTitle={groupTitle}
i18n={i18n}
makeRequest={() => {
makeRequest(selectedConversationIds);
}}
onClose={onCloseConfirmationDialog}
requestState={requestState}
selectedContacts={selectedContacts}
/>
);
return renderConfirmAdditionsModal({
groupTitle,
makeRequest: () => {
makeRequest(selectedConversationIds);
},
onClose: onCloseConfirmationDialog,
requestState,
selectedConversationIds,
});
}
default:
throw missingCaseError(stage);

View file

@ -2,7 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react';
import React, { useEffect, useMemo, useState, useRef } from 'react';
import React, {
useEffect,
useMemo,
useState,
useRef,
useCallback,
} from 'react';
import { omit } from 'lodash';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
@ -11,9 +18,16 @@ import { assert } from '../../../../util/assert';
import { refMerger } from '../../../../util/refMerger';
import { useRestoreFocus } from '../../../../hooks/useRestoreFocus';
import { missingCaseError } from '../../../../util/missingCaseError';
import type { LookupConversationWithoutUuidActionsType } from '../../../../util/lookupConversationWithoutUuid';
import { parseAndFormatPhoneNumber } from '../../../../util/libphonenumberInstance';
import { filterAndSortConversationsByTitle } 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 } from '../../../../util/uuidFetchState';
import { ModalHost } from '../../../ModalHost';
import { ContactPills } from '../../../ContactPills';
import { ContactPill } from '../../../ContactPill';
@ -23,24 +37,37 @@ import { ContactCheckboxDisabledReason } from '../../../conversationList/Contact
import { Button, ButtonVariant } from '../../../Button';
import { SearchInput } from '../../../SearchInput';
type PropsType = {
export type StatePropsType = {
regionCode: string | undefined;
candidateContacts: ReadonlyArray<ConversationType>;
confirmAdds: () => void;
conversationIdsAlreadyInGroup: Set<string>;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
theme: ThemeType;
maxGroupSize: number;
onClose: () => void;
removeSelectedContact: (_: string) => void;
searchTerm: string;
selectedContacts: ReadonlyArray<ConversationType>;
confirmAdds: () => void;
onClose: () => void;
removeSelectedContact: (_: string) => void;
setSearchTerm: (_: string) => void;
theme: ThemeType;
toggleSelectedContact: (conversationId: string) => void;
};
} & Pick<
LookupConversationWithoutUuidActionsType,
'lookupConversationWithoutUuid'
>;
type ActionPropsType = Omit<
LookupConversationWithoutUuidActionsType,
'setIsFetchingUUID' | 'lookupConversationWithoutUuid'
>;
type PropsType = StatePropsType & ActionPropsType;
// TODO: This should use <Modal>. See DESKTOP-1038.
export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
regionCode,
candidateContacts,
confirmAdds,
conversationIdsAlreadyInGroup,
@ -54,9 +81,24 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
setSearchTerm,
theme,
toggleSelectedContact,
lookupConversationWithoutUuid,
showUserNotFoundModal,
}) => {
const [focusRef] = useRestoreFocus();
const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode);
let isPhoneNumberChecked = false;
if (phoneNumber) {
isPhoneNumberChecked =
phoneNumber.isValid &&
selectedContacts.some(contact => contact.e164 === phoneNumber.e164);
}
const isPhoneNumberVisible =
phoneNumber &&
candidateContacts.every(contact => contact.e164 !== phoneNumber.e164);
const inputRef = useRef<null | HTMLInputElement>(null);
const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size;
@ -72,7 +114,7 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
const canContinue = Boolean(selectedContacts.length);
const [filteredContacts, setFilteredContacts] = useState(
filterAndSortConversationsByTitle(candidateContacts, '')
filterAndSortConversationsByTitle(candidateContacts, '', regionCode)
);
const normalizedSearchTerm = searchTerm.trim();
useEffect(() => {
@ -80,38 +122,106 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
setFilteredContacts(
filterAndSortConversationsByTitle(
candidateContacts,
normalizedSearchTerm
normalizedSearchTerm,
regionCode
)
);
}, 200);
return () => {
clearTimeout(timeout);
};
}, [candidateContacts, normalizedSearchTerm, setFilteredContacts]);
}, [
candidateContacts,
normalizedSearchTerm,
setFilteredContacts,
regionCode,
]);
const rowCount = filteredContacts.length;
const [uuidFetchState, setUuidFetchState] = useState<UUIDFetchStateType>({});
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) {
// "Contacts" header
if (filteredContacts.length) {
rowCount += 1;
}
// "Find by phone number" + phone number
rowCount += 2;
}
const getRow = (index: number): undefined | Row => {
const contact = filteredContacts[index];
if (!contact) {
return undefined;
let virtualIndex = index;
if (isPhoneNumberVisible && filteredContacts.length) {
if (virtualIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'contactsHeader',
};
}
virtualIndex -= 1;
}
const isSelected = selectedConversationIdsSet.has(contact.id);
const isAlreadyInGroup = conversationIdsAlreadyInGroup.has(contact.id);
if (virtualIndex < filteredContacts.length) {
const contact = filteredContacts[virtualIndex];
let disabledReason: undefined | ContactCheckboxDisabledReason;
if (isAlreadyInGroup) {
disabledReason = ContactCheckboxDisabledReason.AlreadyAdded;
} else if (hasSelectedMaximumNumberOfContacts && !isSelected) {
disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected;
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,
};
}
return {
type: RowType.ContactCheckbox,
contact,
isChecked: isSelected || isAlreadyInGroup,
disabledReason,
};
virtualIndex -= filteredContacts.length;
if (isPhoneNumberVisible) {
if (virtualIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'findByPhoneNumberHeader',
};
}
if (virtualIndex === 1) {
return {
type: RowType.PhoneNumberCheckbox,
isChecked: isPhoneNumberChecked,
isFetching: isFetchingByE164(uuidFetchState, phoneNumber.e164),
phoneNumber,
};
}
virtualIndex -= 2;
}
return undefined;
};
return (
@ -207,6 +317,12 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
throw missingCaseError(disabledReason);
}
}}
lookupConversationWithoutUuid={
lookupConversationWithoutUuid
}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
showConversation={shouldNeverBeCalled}
onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
@ -215,8 +331,6 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={shouldNeverBeCalled}
startNewConversationFromUsername={shouldNeverBeCalled}
theme={theme}
/>
</div>

View file

@ -15,7 +15,7 @@ import { Intl } from '../../../Intl';
import { Emojify } from '../../Emojify';
import { ContactName } from '../../ContactName';
type PropsType = {
export type StatePropsType = {
groupTitle: string;
i18n: LocalizerType;
makeRequest: () => void;
@ -24,6 +24,8 @@ type PropsType = {
selectedContacts: ReadonlyArray<ConversationType>;
};
type PropsType = StatePropsType;
export const ConfirmAdditionsModal: FunctionComponent<PropsType> = ({
groupTitle,
i18n,

View file

@ -12,8 +12,11 @@ import { CapabilityError } from '../../../types/errors';
import enMessages from '../../../../_locales/en/messages.json';
import type { Props } from './ConversationDetails';
import { ConversationDetails } from './ConversationDetails';
import { ChooseGroupMembersModal } from './AddGroupMembersModal/ChooseGroupMembersModal';
import { ConfirmAdditionsModal } from './AddGroupMembersModal/ConfirmAdditionsModal';
import type { ConversationType } from '../../../state/ducks/conversations';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid';
import { ThemeType } from '../../../types/Util';
const i18n = setupI18n('en', enMessages);
@ -33,13 +36,14 @@ const conversation: ConversationType = getDefaultConversation({
conversationColor: 'ultramarine' as const,
});
const allCandidateContacts = times(10, () => getDefaultConversation());
const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
addMembers: async () => {
action('addMembers');
},
areWeASubscriber: false,
canEditGroupInfo: false,
candidateContactsToAdd: times(10, () => getDefaultConversation()),
conversation: expireTimer
? {
...conversation,
@ -97,6 +101,26 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
),
searchInConversation: action('searchInConversation'),
theme: ThemeType.light,
renderChooseGroupMembersModal: props => {
return (
<ChooseGroupMembersModal
{...props}
candidateContacts={allCandidateContacts}
selectedContacts={[]}
regionCode="US"
getPreferredBadge={() => undefined}
theme={ThemeType.light}
i18n={i18n}
lookupConversationWithoutUuid={makeFakeLookupConversationWithoutUuid()}
showUserNotFoundModal={action('showUserNotFoundModal')}
/>
);
},
renderConfirmAdditionsModal: props => {
return (
<ConfirmAdditionsModal {...props} selectedContacts={[]} i18n={i18n} />
);
},
});
story.add('Basic', () => {

View file

@ -8,6 +8,8 @@ import { Button, ButtonIconType, ButtonVariant } from '../../Button';
import { Tooltip } from '../../Tooltip';
import type { ConversationType } from '../../../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges';
import type { SmartChooseGroupMembersModalPropsType } from '../../../state/smart/ChooseGroupMembersModal';
import type { SmartConfirmAdditionsModalPropsType } from '../../../state/smart/ConfirmAdditionsModal';
import { assert } from '../../../util/assert';
import { getMutedUntilText } from '../../../util/getMutedUntilText';
@ -59,7 +61,6 @@ export type StateProps = {
areWeASubscriber: boolean;
badges?: ReadonlyArray<BadgeType>;
canEditGroupInfo: boolean;
candidateContactsToAdd: Array<ConversationType>;
conversation?: ConversationType;
hasGroupLink: boolean;
getPreferredBadge: PreferredBadgeSelectorType;
@ -97,6 +98,12 @@ export type StateProps = {
setMuteExpiration: (muteExpiresAt: undefined | number) => unknown;
onOutgoingAudioCallInConversation: () => unknown;
onOutgoingVideoCallInConversation: () => unknown;
renderChooseGroupMembersModal: (
props: SmartChooseGroupMembersModalPropsType
) => JSX.Element;
renderConfirmAdditionsModal: (
props: SmartConfirmAdditionsModalPropsType
) => JSX.Element;
};
type ActionProps = {
@ -115,7 +122,6 @@ export const ConversationDetails: React.ComponentType<Props> = ({
areWeASubscriber,
badges,
canEditGroupInfo,
candidateContactsToAdd,
conversation,
deleteAvatarFromDisk,
hasGroupLink,
@ -133,6 +139,8 @@ export const ConversationDetails: React.ComponentType<Props> = ({
onUnblock,
pendingApprovalMemberships,
pendingMemberships,
renderChooseGroupMembersModal,
renderConfirmAdditionsModal,
replaceAvatar,
saveAvatarToDisk,
searchInConversation,
@ -228,7 +236,8 @@ export const ConversationDetails: React.ComponentType<Props> = ({
case ModalState.AddingGroupMembers:
modalNode = (
<AddGroupMembersModal
candidateContacts={candidateContactsToAdd}
renderChooseGroupMembersModal={renderChooseGroupMembersModal}
renderConfirmAdditionsModal={renderConfirmAdditionsModal}
clearRequestError={() => {
setAddGroupMembersRequestState(oldRequestState => {
assert(
@ -241,7 +250,6 @@ export const ConversationDetails: React.ComponentType<Props> = ({
conversationIdsAlreadyInGroup={
new Set(memberships.map(membership => membership.member.id))
}
getPreferredBadge={getPreferredBadge}
groupTitle={conversation.title}
i18n={i18n}
makeRequest={async conversationIds => {
@ -265,7 +273,6 @@ export const ConversationDetails: React.ComponentType<Props> = ({
setEditGroupAttributesRequestState(RequestState.Inactive);
}}
requestState={addGroupMembersRequestState}
theme={theme}
/>
);
break;