Signal-Desktop/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx

404 lines
13 KiB
TypeScript
Raw Normal View History

2022-02-14 17:57:11 +00:00
// Copyright 2021-2022 Signal Messenger, LLC
2021-03-11 21:29:31 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } 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';
2021-03-11 21:29:31 +00:00
2021-11-02 23:01:13 +00:00
import type { LocalizerType, ThemeType } from '../../../../types/Util';
2022-06-17 00:38:28 +00:00
import { getUsernameFromSearch } from '../../../../types/Username';
import { refMerger } from '../../../../util/refMerger';
import { useRestoreFocus } from '../../../../hooks/useRestoreFocus';
2021-03-11 21:29:31 +00:00
import { missingCaseError } from '../../../../util/missingCaseError';
import type { LookupConversationWithoutUuidActionsType } from '../../../../util/lookupConversationWithoutUuid';
import { parseAndFormatPhoneNumber } from '../../../../util/libphonenumberInstance';
import { filterAndSortConversationsByRecent } from '../../../../util/filterAndSortConversations';
import type { ConversationType } from '../../../../state/ducks/conversations';
2021-11-17 21:11:21 +00:00
import type { PreferredBadgeSelectorType } from '../../../../state/selectors/badges';
import type {
UUIDFetchStateKeyType,
UUIDFetchStateType,
} from '../../../../util/uuidFetchState';
2022-06-17 00:38:28 +00:00
import {
isFetchingByE164,
isFetchingByUsername,
} from '../../../../util/uuidFetchState';
2021-03-11 21:29:31 +00:00
import { ModalHost } from '../../../ModalHost';
import { ContactPills } from '../../../ContactPills';
import { ContactPill } from '../../../ContactPill';
import type { Row } from '../../../ConversationList';
import { ConversationList, RowType } from '../../../ConversationList';
2021-03-11 21:29:31 +00:00
import { ContactCheckboxDisabledReason } from '../../../conversationList/ContactCheckbox';
import { Button, ButtonVariant } from '../../../Button';
2021-05-11 00:50:43 +00:00
import { SearchInput } from '../../../SearchInput';
import { shouldNeverBeCalled } from '../../../../util/shouldNeverBeCalled';
2021-03-11 21:29:31 +00:00
export type StatePropsType = {
regionCode: string | undefined;
2021-03-11 21:29:31 +00:00
candidateContacts: ReadonlyArray<ConversationType>;
conversationIdsAlreadyInGroup: Set<string>;
2021-11-17 21:11:21 +00:00
getPreferredBadge: PreferredBadgeSelectorType;
2021-03-11 21:29:31 +00:00
i18n: LocalizerType;
theme: ThemeType;
2021-03-11 21:29:31 +00:00
maxGroupSize: number;
searchTerm: string;
selectedContacts: ReadonlyArray<ConversationType>;
confirmAdds: () => void;
onClose: () => void;
removeSelectedContact: (_: string) => void;
2021-03-11 21:29:31 +00:00
setSearchTerm: (_: string) => void;
toggleSelectedContact: (conversationId: string) => void;
2022-06-17 00:38:28 +00:00
isUsernamesEnabled: boolean;
} & Pick<
LookupConversationWithoutUuidActionsType,
'lookupConversationWithoutUuid'
>;
type ActionPropsType = Omit<
LookupConversationWithoutUuidActionsType,
'setIsFetchingUUID' | 'lookupConversationWithoutUuid'
>;
type PropsType = StatePropsType & ActionPropsType;
2021-03-11 21:29:31 +00:00
// TODO: This should use <Modal>. See DESKTOP-1038.
2021-03-11 21:29:31 +00:00
export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
regionCode,
2021-03-11 21:29:31 +00:00
candidateContacts,
confirmAdds,
conversationIdsAlreadyInGroup,
2021-11-17 21:11:21 +00:00
getPreferredBadge,
2021-03-11 21:29:31 +00:00
i18n,
maxGroupSize,
onClose,
removeSelectedContact,
searchTerm,
selectedContacts,
setSearchTerm,
2021-11-02 23:01:13 +00:00
theme,
2021-03-11 21:29:31 +00:00
toggleSelectedContact,
lookupConversationWithoutUuid,
showUserNotFoundModal,
2022-06-17 00:38:28 +00:00
isUsernamesEnabled,
2021-03-11 21:29:31 +00:00
}) => {
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);
2022-06-17 00:38:28 +00:00
let username: string | undefined;
let isUsernameChecked = false;
let isUsernameVisible = false;
if (!phoneNumber && isUsernamesEnabled) {
username = getUsernameFromSearch(searchTerm);
isUsernameChecked = selectedContacts.some(
contact => contact.username === username
);
2022-07-08 20:46:25 +00:00
isUsernameVisible =
Boolean(username) &&
candidateContacts.every(contact => contact.username !== username);
2022-06-17 00:38:28 +00:00
}
2021-03-11 21:29:31 +00:00
const inputRef = useRef<null | HTMLInputElement>(null);
const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size;
const hasSelectedMaximumNumberOfContacts =
selectedContacts.length + numberOfContactsAlreadyInGroup >= maxGroupSize;
const selectedConversationIdsSet: Set<string> = useMemo(
() => new Set(selectedContacts.map(contact => contact.id)),
[selectedContacts]
);
const canContinue = Boolean(selectedContacts.length);
const [filteredContacts, setFilteredContacts] = useState(
filterAndSortConversationsByRecent(candidateContacts, '', regionCode)
2021-03-11 21:29:31 +00:00
);
const normalizedSearchTerm = searchTerm.trim();
useEffect(() => {
const timeout = setTimeout(() => {
setFilteredContacts(
filterAndSortConversationsByRecent(
2021-04-28 20:44:48 +00:00
candidateContacts,
normalizedSearchTerm,
regionCode
2021-04-28 20:44:48 +00:00
)
2021-03-11 21:29:31 +00:00
);
}, 200);
return () => {
clearTimeout(timeout);
};
}, [
candidateContacts,
normalizedSearchTerm,
setFilteredContacts,
regionCode,
]);
2021-03-11 21:29:31 +00:00
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;
}
2022-06-17 00:38:28 +00:00
if (isPhoneNumberVisible || isUsernameVisible) {
// "Contacts" header
if (filteredContacts.length) {
rowCount += 1;
}
// "Find by phone number" + phone number
2022-06-17 00:38:28 +00:00
// or
// "Find by username" + username
rowCount += 2;
}
2021-03-11 21:29:31 +00:00
const getRow = (index: number): undefined | Row => {
let virtualIndex = index;
2022-06-17 00:38:28 +00:00
if (
(isPhoneNumberVisible || isUsernameVisible) &&
filteredContacts.length
) {
if (virtualIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'contactsHeader',
};
}
virtualIndex -= 1;
2021-03-11 21:29:31 +00:00
}
if (virtualIndex < filteredContacts.length) {
const contact = filteredContacts[virtualIndex];
2021-03-11 21:29:31 +00:00
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,
};
2021-03-11 21:29:31 +00:00
}
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;
}
2022-06-17 00:38:28 +00:00
if (username) {
if (virtualIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
};
}
if (virtualIndex === 1) {
return {
type: RowType.UsernameCheckbox,
isChecked: isUsernameChecked,
isFetching: isFetchingByUsername(uuidFetchState, username),
username,
};
}
virtualIndex -= 2;
}
return undefined;
2021-03-11 21:29:31 +00:00
};
return (
2022-09-27 20:24:21 +00:00
<ModalHost
modalName="AddGroupMembersModal.ChooseGroupMembersModal"
onClose={onClose}
>
2021-03-11 21:29:31 +00:00
<div className="module-AddGroupMembersModal module-AddGroupMembersModal--choose-members">
<button
aria-label={i18n('close')}
className="module-AddGroupMembersModal__close-button"
type="button"
onClick={() => {
onClose();
}}
/>
<h1 className="module-AddGroupMembersModal__header">
{i18n('AddGroupMembersModal--title')}
</h1>
2021-05-11 00:50:43 +00:00
<SearchInput
2022-02-14 17:57:11 +00:00
i18n={i18n}
2021-03-11 21:29:31 +00:00
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
onKeyDown={event => {
if (canContinue && event.key === 'Enter') {
confirmAdds();
}
}}
ref={refMerger<HTMLInputElement>(inputRef, focusRef)}
2021-03-11 21:29:31 +00:00
value={searchTerm}
/>
{Boolean(selectedContacts.length) && (
<ContactPills>
{selectedContacts.map(contact => (
<ContactPill
key={contact.id}
acceptedMessageRequest={contact.acceptedMessageRequest}
2021-03-11 21:29:31 +00:00
avatarPath={contact.avatarPath}
color={contact.color}
firstName={contact.systemGivenName ?? contact.firstName}
2021-03-11 21:29:31 +00:00
i18n={i18n}
2021-05-07 22:21:10 +00:00
isMe={contact.isMe}
2021-03-11 21:29:31 +00:00
id={contact.id}
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
2021-05-07 22:21:10 +00:00
sharedGroupNames={contact.sharedGroupNames}
2021-03-11 21:29:31 +00:00
title={contact.title}
onClickRemove={() => {
removeSelectedContact(contact.id);
}}
/>
))}
</ContactPills>
)}
{rowCount ? (
2021-03-11 21:29:31 +00:00
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => {
// We disable this ESLint rule because we're capturing a bubbled keydown
// event. See [this note in the jsx-a11y docs][0].
//
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<div
className="module-AddGroupMembersModal__list-wrapper"
ref={measureRef}
onKeyDown={event => {
if (event.key === 'Enter') {
inputRef.current?.focus();
}
}}
>
<ConversationList
dimensions={contentRect.bounds}
2021-11-17 21:11:21 +00:00
getPreferredBadge={getPreferredBadge}
2021-03-11 21:29:31 +00:00
getRow={getRow}
i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={(
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);
}
}}
lookupConversationWithoutUuid={
lookupConversationWithoutUuid
}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
showConversation={shouldNeverBeCalled}
2021-03-11 21:29:31 +00:00
onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
2021-11-02 23:01:13 +00:00
theme={theme}
2021-03-11 21:29:31 +00:00
/>
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}}
</Measure>
) : (
<div className="module-AddGroupMembersModal__no-candidate-contacts">
{i18n('noContactsFound')}
</div>
)}
<div className="module-AddGroupMembersModal__button-container">
<Button onClick={onClose} variant={ButtonVariant.Secondary}>
{i18n('cancel')}
</Button>
<Button disabled={!canContinue} onClick={confirmAdds}>
{i18n('AddGroupMembersModal--continue-to-confirm')}
</Button>
</div>
</div>
</ModalHost>
);
};