From b81a52bbdd00bbb47db10d89d1052df869d12d13 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Thu, 11 Mar 2021 15:29:31 -0600 Subject: [PATCH] New Group administration: Add users --- _locales/en/messages.json | 58 +++- stylesheets/_mixins.scss | 53 +++ stylesheets/_modules.scss | 49 ++- .../components/AddGroupMembersModal.scss | 97 ++++++ stylesheets/components/Alert.scss | 2 +- .../EditConversationAttributesModal.scss | 47 +-- stylesheets/manifest.scss | 1 + .../AddGroupMemberErrorDialog.stories.tsx | 50 +++ ts/components/AddGroupMemberErrorDialog.tsx | 90 +++++ ts/components/Alert.stories.tsx | 41 +++ ts/components/Alert.tsx | 4 +- ts/components/ConversationList.stories.tsx | 8 + ts/components/LeftPane.tsx | 3 +- .../AddGroupMembersModal.stories.tsx | 92 +++++ .../AddGroupMembersModal.tsx | 320 ++++++++++++++++++ .../ChooseGroupMembersModal.tsx | 250 ++++++++++++++ .../ConfirmAdditionsModal.tsx | 109 ++++++ .../ConversationDetails.stories.tsx | 5 + .../ConversationDetails.tsx | 160 ++++++--- ...versationDetailsMembershipList.stories.tsx | 15 +- .../ConversationDetailsMembershipList.tsx | 15 +- ...ditConversationAttributesModal.stories.tsx | 6 +- .../EditConversationAttributesModal.tsx | 7 +- .../conversation/conversation-details/util.ts | 8 +- .../BaseConversationListItem.tsx | 5 + .../conversationList/ContactCheckbox.tsx | 14 +- .../LeftPaneChooseGroupMembersHelper.tsx | 69 ++-- .../leftPane/LeftPaneComposeHelper.tsx | 4 +- ts/groups.ts | 142 ++++++++ .../toggleSelectedContactForGroupAddition.ts | 68 ++++ ts/models/conversations.ts | 16 + ts/state/ducks/conversations.ts | 54 +-- ts/state/selectors/conversations.ts | 65 +--- ts/state/smart/ConversationDetails.tsx | 8 +- ts/state/smart/LeftPane.tsx | 4 +- .../helpers/getDefaultConversation.ts | 19 +- .../state/selectors/conversations_test.ts | 20 +- .../util/filterAndSortContacts_test.ts | 41 +++ .../state/ducks/conversations_test.ts | 2 +- ts/util/filterAndSortContacts.ts | 27 ++ ts/util/lint/exceptions.json | 13 +- ts/util/makeLookup.ts | 4 +- ts/views/conversation_view.ts | 1 + 43 files changed, 1789 insertions(+), 277 deletions(-) create mode 100644 stylesheets/components/AddGroupMembersModal.scss create mode 100644 ts/components/AddGroupMemberErrorDialog.stories.tsx create mode 100644 ts/components/AddGroupMemberErrorDialog.tsx create mode 100644 ts/components/Alert.stories.tsx create mode 100644 ts/components/conversation/conversation-details/AddGroupMembersModal.stories.tsx create mode 100644 ts/components/conversation/conversation-details/AddGroupMembersModal.tsx create mode 100644 ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx create mode 100644 ts/components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal.tsx create mode 100644 ts/groups/toggleSelectedContactForGroupAddition.ts create mode 100644 ts/test-both/util/filterAndSortContacts_test.ts create mode 100644 ts/util/filterAndSortContacts.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a008ad3bc..5fcecf597 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1897,11 +1897,11 @@ "message": "New conversation", "description": "Label for header when starting a new conversation" }, - "newConversationContactSearchPlaceholder": { + "contactSearchPlaceholder": { "message": "Search by name or phone number", "description": "Placeholder to use when searching for contacts in the composer" }, - "newConversationNoContacts": { + "noContactsFound": { "message": "No contacts found", "description": "Label shown when there are no contacts to compose to" }, @@ -1954,7 +1954,7 @@ "description": "Shown in the alert when you try to add someone who can't be added to a group" }, "chooseGroupMembers__cant-add-member__body": { - "message": "“$name$” can’t be added to the group because they’re using an old version of Signal. You can add them to the group after they’ve updated Signal.", + "message": "\"$name$\" can’t be added to the group because they’re using an old version of Signal. You can add them to the group after they’ve updated Signal.", "description": "Shown in the alert when you try to add someone who can't be added to a group", "placeholders": { "max": { @@ -4809,6 +4809,10 @@ } } }, + "ConversationDetailsMembershipList--add-members": { + "message": "Add members", + "description": "The button that you can click to add new members" + }, "ConversationDetailsMembershipList--show-all": { "message": "See all", "description": "This is a button on the conversation details to show all members" @@ -5027,6 +5031,50 @@ "message": "Learn more", "description": "When creating a new group and inviting users, this is shown in the dialog" }, + "AddGroupMembersModal--title": { + "message": "Add members", + "description": "When adding new members to an existing group, this is shown in the dialog" + }, + "AddGroupMembersModal--continue-to-confirm": { + "message": "Update", + "description": "When adding new members to an existing group, this is shown in the dialog" + }, + "AddGroupMembersModal--confirm-title--one": { + "message": "Add $person$ to \"$group$\"?", + "description": "When adding new members to an existing group, this is shown in the confirmation dialog", + "placeholders": { + "person": { + "content": "$1", + "example": "Jane Doe" + }, + "group": { + "content": "$2", + "example": "Tahoe Trip" + } + } + }, + "AddGroupMembersModal--confirm-title--many": { + "message": "Add $count$ members to \"$group$\"?", + "description": "When adding new members to an existing group, this is shown in the confirmation dialog", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + }, + "group": { + "content": "$2", + "example": "Tahoe Trip" + } + } + }, + "AddGroupMembersModal--confirm-button--one": { + "message": "Add member", + "description": "When adding new members to an existing group, this is shown on the confirmation dialog button" + }, + "AddGroupMembersModal--confirm-button--many": { + "message": "Add members", + "description": "When adding new members to an existing group, this is shown on the confirmation dialog button" + }, "createNewGroupButton": { "message": "New group", "description": "The text of the button to create new groups" @@ -5043,6 +5091,10 @@ "message": "Cannot select contact", "description": "The label for contact checkboxes that are disabled" }, + "alreadyAMember": { + "message": "Already a member", + "description": "The label for contact checkboxes that are disabled because they're already a member" + }, "MessageAudio--play": { "message": "Play audio attachment", "description": "Aria label for audio attachment's Play button" diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index bcca14f2a..63e414d32 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -429,3 +429,56 @@ border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.) padding: 7px 14px; } + +// Modals + +@mixin modal-reset { + @include popper-shadow(); + border-radius: 8px; + margin: 0 auto; + max-height: 100%; + max-width: 360px; + padding: 16px; + position: relative; + width: 95%; + display: flex; + flex-direction: column; + + @include light-theme() { + background: $color-white; + color: $color-gray-90; + } + + @include dark-theme() { + background: $color-gray-95; + color: $color-gray-05; + } +} + +@mixin modal-close-button { + @include button-reset; + + position: absolute; + right: 12px; + top: 12px; + + height: 24px; + width: 24px; + + @include light-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); + } + + @include dark-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); + } + + &:focus { + @include keyboard-mode { + background-color: $ultramarine-ui-light; + } + @include dark-keyboard-mode { + background-color: $ultramarine-ui-dark; + } + } +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 16ac35c52..e7c46c16b 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2925,6 +2925,38 @@ button.module-conversation-details__action-button { } } + &-membership-list { + &__add-members-icon { + @mixin plus-icon($color) { + @include color-svg('../images/icons/v2/plus-24.svg', $color); + content: ''; + display: block; + height: 16px; + width: 16px; + } + + align-items: center; + border-radius: 100%; + display: flex; + height: 32px; + justify-content: center; + width: 32px; + + @include light-theme { + background: $color-gray-02; + &::before { + @include plus-icon($color-black); + } + } + @include dark-theme { + background: $color-gray-90; + &::before { + @include plus-icon($color-gray-15); + } + } + } + } + &__leave-group { color: $color-accent-red; } @@ -7269,11 +7301,13 @@ button.module-image__border-overlay:focus { } } - &:disabled { + &:disabled:not(:checked) { opacity: 0.5; } &:checked { + $icon: '../images/icons/v2/check-24.svg'; + background: $ultramarine-ui-light; display: flex; align-items: center; @@ -7282,10 +7316,21 @@ button.module-image__border-overlay:focus { &::before { content: ''; display: block; - @include color-svg('../images/icons/v2/check-24.svg', $color-white); + @include color-svg($icon, $color-white); width: 13px; height: 13px; } + + @include light-theme { + &:disabled { + background: $color-gray-15; + } + } + @include dark-theme { + &:disabled { + background: $color-gray-45; + } + } } } } diff --git a/stylesheets/components/AddGroupMembersModal.scss b/stylesheets/components/AddGroupMembersModal.scss new file mode 100644 index 000000000..34d9af2c9 --- /dev/null +++ b/stylesheets/components/AddGroupMembersModal.scss @@ -0,0 +1,97 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-AddGroupMembersModal { + $root-selector: &; + $padding: 16px; + + &__header { + @include font-body-1-bold; + margin: 0; + padding: 0; + } + + &__button-container { + display: flex; + justify-content: flex-end; + flex-grow: 0; + flex-shrink: 0; + padding: $padding; + + .module-Button { + &:not(:first-child) { + margin-left: 12px; + } + } + } + + &__close-button { + @include modal-close-button; + } + + &__search-input { + margin: 10px $padding; + padding: 5px 12px; + + border-radius: 17px; + border: none; + + @include font-body-2; + + @include light-theme { + background-color: $color-gray-05; + color: $color-gray-90; + border: solid 1px $color-gray-02; + } + @include dark-theme { + color: $color-gray-05; + background-color: $color-gray-95; + border: solid 1px $color-gray-80; + } + + &:placeholder { + color: $color-gray-45; + } + + &:focus { + border: solid 1px $ultramarine-ui-light; + outline: none; + } + } + + .module-ContactPills { + max-height: 50px; + } + + &__list-wrapper { + flex-grow: 1; + overflow: hidden; + } + + &__no-candidate-contacts { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + } + + &--choose-members { + @include modal-reset; + padding: 0; // The has its own padding, so we pad various inner elements. + height: 60vh; + min-height: 400px; + + '#{$root-selector}__header' { + padding: $padding; + } + } + + &--confirm-adds { + @include modal-reset; + + '#{$root-selector}__button-container' { + margin-top: 12px; + padding: 0; + } + } +} diff --git a/stylesheets/components/Alert.scss b/stylesheets/components/Alert.scss index ca7437bab..460215176 100644 --- a/stylesheets/components/Alert.scss +++ b/stylesheets/components/Alert.scss @@ -21,7 +21,7 @@ &__title { @include font-body-1-bold; - margin: 0; + margin: 0 0 1em 0; padding: 0; } diff --git a/stylesheets/components/EditConversationAttributesModal.scss b/stylesheets/components/EditConversationAttributesModal.scss index 15f6e317b..42c771034 100644 --- a/stylesheets/components/EditConversationAttributesModal.scss +++ b/stylesheets/components/EditConversationAttributesModal.scss @@ -2,53 +2,10 @@ // SPDX-License-Identifier: AGPL-3.0-only .module-EditConversationAttributesModal { - @include popper-shadow(); - border-radius: 8px; - margin: 0 auto; - max-height: 100%; - max-width: 360px; - padding: 16px; - position: relative; - width: 95%; - display: flex; - flex-direction: column; - - @include light-theme() { - background: $color-white; - color: $color-gray-90; - } - - @include dark-theme() { - background: $color-gray-95; - color: $color-gray-05; - } + @include modal-reset; &__close-button { - @include button-reset; - - position: absolute; - right: 12px; - top: 12px; - - height: 24px; - width: 24px; - - @include light-theme { - @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); - } - - @include dark-theme { - @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); - } - - &:focus { - @include keyboard-mode { - background-color: $ultramarine-ui-light; - } - @include dark-keyboard-mode { - background-color: $ultramarine-ui-dark; - } - } + @include modal-close-button; } &__header { diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 3d13963e6..a629149c0 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -27,6 +27,7 @@ @import 'options'; // New style: components +@import './components/AddGroupMembersModal.scss'; @import './components/Alert.scss'; @import './components/AvatarInput.scss'; @import './components/Button.scss'; diff --git a/ts/components/AddGroupMemberErrorDialog.stories.tsx b/ts/components/AddGroupMemberErrorDialog.stories.tsx new file mode 100644 index 000000000..0e53eb033 --- /dev/null +++ b/ts/components/AddGroupMemberErrorDialog.stories.tsx @@ -0,0 +1,50 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; +import { + AddGroupMemberErrorDialog, + AddGroupMemberErrorDialogMode, +} from './AddGroupMemberErrorDialog'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/AddGroupMemberErrorDialog', module); + +const defaultProps = { + i18n, + onClose: action('onClose'), +}; + +story.add("Can't add a contact", () => ( + +)); + +story.add('Maximum group size', () => ( + +)); + +story.add('Maximum recommended group size', () => ( + +)); diff --git a/ts/components/AddGroupMemberErrorDialog.tsx b/ts/components/AddGroupMemberErrorDialog.tsx new file mode 100644 index 000000000..296ab088b --- /dev/null +++ b/ts/components/AddGroupMemberErrorDialog.tsx @@ -0,0 +1,90 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, ReactNode } from 'react'; + +import { LocalizerType } from '../types/Util'; +import { Alert } from './Alert'; +import { Intl } from './Intl'; +import { ContactName } from './conversation/ContactName'; +import { missingCaseError } from '../util/missingCaseError'; + +export enum AddGroupMemberErrorDialogMode { + CantAddContact, + MaximumGroupSize, + RecommendedMaximumGroupSize, +} + +type PropsDataType = + | { + mode: AddGroupMemberErrorDialogMode.CantAddContact; + contact: { + name?: string; + phoneNumber?: string; + profileName?: string; + title: string; + }; + } + | { + mode: AddGroupMemberErrorDialogMode.MaximumGroupSize; + maximumNumberOfContacts: number; + } + | { + mode: AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize; + recommendedMaximumNumberOfContacts: number; + }; + +type PropsType = { + i18n: LocalizerType; + onClose: () => void; +} & PropsDataType; + +export const AddGroupMemberErrorDialog: FunctionComponent = props => { + const { i18n, onClose } = props; + + let title: string; + let body: ReactNode; + switch (props.mode) { + case AddGroupMemberErrorDialogMode.CantAddContact: { + const { contact } = props; + title = i18n('chooseGroupMembers__cant-add-member__title'); + body = ( + , + ]} + /> + ); + break; + } + case AddGroupMemberErrorDialogMode.MaximumGroupSize: { + const { maximumNumberOfContacts } = props; + title = i18n('chooseGroupMembers__maximum-group-size__title'); + body = i18n('chooseGroupMembers__maximum-group-size__body', [ + maximumNumberOfContacts.toString(), + ]); + break; + } + case AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize: { + const { recommendedMaximumNumberOfContacts } = props; + title = i18n('chooseGroupMembers__maximum-recommended-group-size__title'); + body = i18n('chooseGroupMembers__maximum-recommended-group-size__body', [ + recommendedMaximumNumberOfContacts.toString(), + ]); + break; + } + default: + throw missingCaseError(props); + } + + return ; +}; diff --git a/ts/components/Alert.stories.tsx b/ts/components/Alert.stories.tsx new file mode 100644 index 000000000..db7b6594c --- /dev/null +++ b/ts/components/Alert.stories.tsx @@ -0,0 +1,41 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; +import { Alert } from './Alert'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/Alert', module); + +const defaultProps = { + i18n, + onClose: action('onClose'), +}; + +story.add('Title and body are strings', () => ( + +)); + +story.add('Body is a ReactNode', () => ( + + Hello{' '} + world! + + } + /> +)); diff --git a/ts/components/Alert.tsx b/ts/components/Alert.tsx index 4c3c45731..183276277 100644 --- a/ts/components/Alert.tsx +++ b/ts/components/Alert.tsx @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, ReactNode } from 'react'; import { LocalizerType } from '../types/Util'; import { Button } from './Button'; @@ -9,7 +9,7 @@ import { ModalHost } from './ModalHost'; type PropsType = { title?: string; - body: string; + body: ReactNode; i18n: LocalizerType; onClose: () => void; }; diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx index 6b6252513..6b94a0960 100644 --- a/ts/components/ConversationList.stories.tsx +++ b/ts/components/ConversationList.stories.tsx @@ -15,6 +15,7 @@ import { MessageStatuses, } from './conversationList/ConversationListItem'; import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; +import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; @@ -49,6 +50,7 @@ const defaultConversations: Array = [ 'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso', type: 'direct', }, + getDefaultConversation(), ]; const createProps = (rows: ReadonlyArray): PropsType => ({ @@ -204,6 +206,12 @@ story.add('Contact checkboxes: disabled', () => ( isChecked: true, disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected, }, + { + type: RowType.ContactCheckbox, + contact: defaultConversations[3], + isChecked: true, + disabledReason: ContactCheckboxDisabledReason.AlreadyAdded, + }, ])} /> )); diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index b23b4b27c..46e235121 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -380,8 +380,9 @@ export const LeftPane: React.FC = ({ case undefined: toggleConversationInChooseMembers(conversationId); break; + case ContactCheckboxDisabledReason.AlreadyAdded: case ContactCheckboxDisabledReason.MaximumContactsSelected: - // This is a no-op. + // These are no-ops. break; case ContactCheckboxDisabledReason.NotCapable: cantAddContactToGroup(conversationId); diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal.stories.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal.stories.tsx new file mode 100644 index 000000000..c068d4471 --- /dev/null +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal.stories.tsx @@ -0,0 +1,92 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ComponentProps, useState } from 'react'; +import { times } from 'lodash'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { sleep } from '../../../util/sleep'; +import { setup as setupI18n } from '../../../../js/modules/i18n'; +import enMessages from '../../../../_locales/en/messages.json'; +import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; +import { AddGroupMembersModal } from './AddGroupMembersModal'; +import { RequestState } from './util'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/AddGroupMembersModal', + module +); + +const allCandidateContacts = times(50, () => getDefaultConversation()); + +type PropsType = ComponentProps; + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + candidateContacts: allCandidateContacts, + clearRequestError: action('clearRequestError'), + conversationIdsAlreadyInGroup: new Set(), + groupTitle: 'Tahoe Trip', + i18n, + onClose: action('onClose'), + makeRequest: async (conversationIds: ReadonlyArray) => { + action('onMakeRequest')(conversationIds); + }, + requestState: RequestState.Inactive, + ...overrideProps, +}); + +story.add('Default', () => ); + +story.add('Only 3 contacts', () => ( + +)); + +story.add('No candidate contacts', () => ( + +)); + +story.add('Everyone already added', () => ( + contact.id) + ), + })} + /> +)); + +story.add('Request fails after 1 second', () => { + const Wrapper = () => { + const [requestState, setRequestState] = useState(RequestState.Inactive); + + return ( + { + setRequestState(RequestState.Inactive); + }, + makeRequest: async () => { + setRequestState(RequestState.Active); + await sleep(1000); + setRequestState(RequestState.InactiveWithError); + }, + requestState, + })} + /> + ); + }; + + return ; +}); diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx new file mode 100644 index 000000000..a190904f3 --- /dev/null +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx @@ -0,0 +1,320 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, useMemo, useReducer } from 'react'; +import { without } from 'lodash'; + +import { LocalizerType } from '../../../types/Util'; +import { + AddGroupMemberErrorDialog, + AddGroupMemberErrorDialogMode, +} from '../../AddGroupMemberErrorDialog'; +import { ConversationType } from '../../../state/ducks/conversations'; +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 { RequestState } from './util'; +import { ChooseGroupMembersModal } from './AddGroupMembersModal/ChooseGroupMembersModal'; +import { ConfirmAdditionsModal } from './AddGroupMembersModal/ConfirmAdditionsModal'; + +type PropsType = { + candidateContacts: ReadonlyArray; + clearRequestError: () => void; + conversationIdsAlreadyInGroup: Set; + groupTitle: string; + i18n: LocalizerType; + makeRequest: (conversationIds: ReadonlyArray) => Promise; + onClose: () => void; + requestState: RequestState; +}; + +enum Stage { + ChoosingContacts, + ConfirmingAdds, +} + +type StateType = { + cantAddContactForModal: undefined | ConversationType; + maximumGroupSizeModalState: OneTimeModalState; + recommendedGroupSizeModalState: OneTimeModalState; + searchTerm: string; + selectedConversationIds: Array; + stage: Stage; +}; + +enum ActionType { + CloseMaximumGroupSizeModal, + CloseRecommendedMaximumGroupSizeModal, + ConfirmAdds, + RemoveSelectedContact, + ReturnToContactChooser, + SetCantAddContactForModal, + ToggleSelectedContact, + UpdateSearchTerm, +} + +type Action = + | { type: ActionType.CloseMaximumGroupSizeModal } + | { type: ActionType.CloseRecommendedMaximumGroupSizeModal } + | { type: ActionType.ConfirmAdds } + | { type: ActionType.ReturnToContactChooser } + | { type: ActionType.RemoveSelectedContact; conversationId: string } + | { + type: ActionType.SetCantAddContactForModal; + contact: undefined | ConversationType; + } + | { + 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.SetCantAddContactForModal: + return { + ...state, + cantAddContactForModal: action.contact, + }; + 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, + groupTitle, + i18n, + onClose, + makeRequest, + requestState, +}) => { + const maxGroupSize = getMaximumNumberOfContacts(); + const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts(); + + const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size; + const isGroupAlreadyFull = numberOfContactsAlreadyInGroup >= maxGroupSize; + const isGroupAlreadyOverRecommendedMaximum = + numberOfContactsAlreadyInGroup >= maxRecommendedGroupSize; + + const [ + { + cantAddContactForModal, + maximumGroupSizeModalState, + recommendedGroupSizeModalState, + searchTerm, + selectedConversationIds, + stage, + }, + dispatch, + ] = useReducer(reducer, { + cantAddContactForModal: undefined, + 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 (cantAddContactForModal) { + return ( + { + dispatch({ + type: ActionType.SetCantAddContactForModal, + contact: undefined, + }); + }} + /> + ); + } + + 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 setCantAddContactForModal = ( + contact: undefined | Readonly + ) => { + dispatch({ + type: ActionType.SetCantAddContactForModal, + contact, + }); + }; + 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); +} diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx new file mode 100644 index 000000000..fb61d25d0 --- /dev/null +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx @@ -0,0 +1,250 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { + FunctionComponent, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import Measure, { MeasuredComponentProps } from 'react-measure'; + +import { LocalizerType } from '../../../../types/Util'; +import { assert } from '../../../../util/assert'; +import { getOwn } from '../../../../util/getOwn'; +import { missingCaseError } from '../../../../util/missingCaseError'; +import { filterAndSortContacts } from '../../../../util/filterAndSortContacts'; +import { ConversationType } from '../../../../state/ducks/conversations'; +import { ModalHost } from '../../../ModalHost'; +import { ContactPills } from '../../../ContactPills'; +import { ContactPill } from '../../../ContactPill'; +import { ConversationList, Row, RowType } from '../../../ConversationList'; +import { ContactCheckboxDisabledReason } from '../../../conversationList/ContactCheckbox'; +import { Button, ButtonVariant } from '../../../Button'; + +type PropsType = { + candidateContacts: ReadonlyArray; + confirmAdds: () => void; + contactLookup: Record; + conversationIdsAlreadyInGroup: Set; + i18n: LocalizerType; + maxGroupSize: number; + onClose: () => void; + removeSelectedContact: (_: string) => void; + searchTerm: string; + selectedContacts: ReadonlyArray; + setCantAddContactForModal: ( + _: Readonly + ) => void; + setSearchTerm: (_: string) => void; + toggleSelectedContact: (conversationId: string) => void; +}; + +export const ChooseGroupMembersModal: FunctionComponent = ({ + candidateContacts, + confirmAdds, + contactLookup, + conversationIdsAlreadyInGroup, + i18n, + maxGroupSize, + onClose, + removeSelectedContact, + searchTerm, + selectedContacts, + setCantAddContactForModal, + setSearchTerm, + toggleSelectedContact, +}) => { + 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( + filterAndSortContacts(candidateContacts, '') + ); + const normalizedSearchTerm = searchTerm.trim(); + useEffect(() => { + const timeout = setTimeout(() => { + setFilteredContacts( + filterAndSortContacts(candidateContacts, normalizedSearchTerm) + ); + }, 200); + return () => { + clearTimeout(timeout); + }; + }, [candidateContacts, normalizedSearchTerm, setFilteredContacts]); + + const rowCount = filteredContacts.length; + const getRow = (index: number): undefined | Row => { + const contact = filteredContacts[index]; + if (!contact) { + return undefined; + } + + 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; + } else if (!contact.isGroupV2Capable) { + disabledReason = ContactCheckboxDisabledReason.NotCapable; + } + + return { + type: RowType.ContactCheckbox, + contact, + isChecked: isSelected || isAlreadyInGroup, + disabledReason, + }; + }; + + return ( + +
+ + + +
+ +
+ ); +}; + +function shouldNeverBeCalled(..._args: ReadonlyArray): unknown { + assert(false, 'This should never be called. Doing nothing'); +} diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal.tsx new file mode 100644 index 000000000..b4175f67f --- /dev/null +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal.tsx @@ -0,0 +1,109 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, ReactNode } from 'react'; + +import { LocalizerType } from '../../../../types/Util'; +import { assert } from '../../../../util/assert'; +import { ModalHost } from '../../../ModalHost'; +import { Button, ButtonVariant } from '../../../Button'; +import { Spinner } from '../../../Spinner'; +import { ConversationType } from '../../../../state/ducks/conversations'; +import { RequestState } from '../util'; +import { Intl } from '../../../Intl'; +import { Emojify } from '../../Emojify'; +import { ContactName } from '../../ContactName'; + +type PropsType = { + groupTitle: string; + i18n: LocalizerType; + makeRequest: () => void; + onClose: () => void; + requestState: RequestState; + selectedContacts: ReadonlyArray; +}; + +export const ConfirmAdditionsModal: FunctionComponent = ({ + groupTitle, + i18n, + makeRequest, + onClose, + requestState, + selectedContacts, +}) => { + const firstContact = selectedContacts[0]; + assert( + firstContact, + 'Expected at least one conversation to be selected but none were picked' + ); + + const groupTitleNode: JSX.Element = ; + + let headerText: ReactNode; + if (selectedContacts.length === 1) { + headerText = ( + + ), + group: groupTitleNode, + }} + /> + ); + } else { + headerText = ( + + ); + } + + let buttonContents: ReactNode; + if (requestState === RequestState.Active) { + buttonContents = ( + + ); + } else if (selectedContacts.length === 1) { + buttonContents = i18n('AddGroupMembersModal--confirm-button--one'); + } else { + buttonContents = i18n('AddGroupMembersModal--confirm-button--many'); + } + + return ( + +
+

{headerText}

+ {requestState === RequestState.InactiveWithError && ( +
+ {i18n('updateGroupAttributes__error-message')} +
+ )} +
+ + + +
+
+
+ ); +}; diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 457ff6bce..806a2a1dc 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; +import { times } from 'lodash'; import { setup as setupI18n } from '../../../../js/modules/i18n'; import enMessages from '../../../../_locales/en/messages.json'; @@ -47,7 +48,11 @@ const conversation: ConversationType = { }; const createProps = (hasGroupLink = false): Props => ({ + addMembers: async () => { + action('addMembers'); + }, canEditGroupInfo: false, + candidateContactsToAdd: times(10, () => getDefaultConversation()), conversation, hasGroupLink, i18n, diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 9d150412e..73afc6942 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -1,30 +1,39 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useState } from 'react'; +import React, { useState, ReactNode } from 'react'; import { ConversationType } from '../../../state/ducks/conversations'; +import { assert } from '../../../util/assert'; import { ExpirationTimerOptions, TimerOption, } from '../../../util/ExpirationTimerOptions'; import { LocalizerType } from '../../../types/Util'; import { MediaItemType } from '../../LightboxGallery'; +import { missingCaseError } from '../../../util/missingCaseError'; import { PanelRow } from './PanelRow'; import { PanelSection } from './PanelSection'; +import { AddGroupMembersModal } from './AddGroupMembersModal'; import { ConversationDetailsActions } from './ConversationDetailsActions'; import { ConversationDetailsHeader } from './ConversationDetailsHeader'; import { ConversationDetailsIcon } from './ConversationDetailsIcon'; import { ConversationDetailsMediaList } from './ConversationDetailsMediaList'; import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList'; -import { - EditConversationAttributesModal, - RequestState as EditGroupAttributesRequestState, -} from './EditConversationAttributesModal'; +import { EditConversationAttributesModal } from './EditConversationAttributesModal'; +import { RequestState } from './util'; + +enum ModalState { + NothingOpen, + EditingGroupAttributes, + AddingGroupMembers, +} export type StateProps = { + addMembers: (conversationIds: ReadonlyArray) => Promise; canEditGroupInfo: boolean; + candidateContactsToAdd: Array; conversation?: ConversationType; hasGroupLink: boolean; i18n: LocalizerType; @@ -53,7 +62,9 @@ export type StateProps = { export type Props = StateProps; export const ConversationDetails: React.ComponentType = ({ + addMembers, canEditGroupInfo, + candidateContactsToAdd, conversation, hasGroupLink, i18n, @@ -70,15 +81,17 @@ export const ConversationDetails: React.ComponentType = ({ onBlockAndDelete, onDelete, }) => { - const [isEditingGroupAttributes, setIsEditingGroupAttributes] = useState( - false + const [modalState, setModalState] = useState( + ModalState.NothingOpen ); const [ editGroupAttributesRequestState, setEditGroupAttributesRequestState, - ] = useState( - EditGroupAttributesRequestState.Inactive - ); + ] = useState(RequestState.Inactive); + const [ + addGroupMembersRequestState, + setAddGroupMembersRequestState, + ] = useState(RequestState.Inactive); const updateExpireTimer = (event: React.ChangeEvent) => { setDisappearingMessages(parseInt(event.target.value, 10)); @@ -94,6 +107,88 @@ export const ConversationDetails: React.ComponentType = ({ const invitesCount = pendingMemberships.length + pendingApprovalMemberships.length; + let modalNode: ReactNode; + switch (modalState) { + case ModalState.NothingOpen: + modalNode = undefined; + break; + case ModalState.EditingGroupAttributes: + modalNode = ( + + ) => { + setEditGroupAttributesRequestState(RequestState.Active); + + try { + await updateGroupAttributes(options); + setModalState(ModalState.NothingOpen); + setEditGroupAttributesRequestState(RequestState.Inactive); + } catch (err) { + setEditGroupAttributesRequestState( + RequestState.InactiveWithError + ); + } + }} + onClose={() => { + setModalState(ModalState.NothingOpen); + setEditGroupAttributesRequestState(RequestState.Inactive); + }} + requestState={editGroupAttributesRequestState} + title={conversation.title} + /> + ); + break; + case ModalState.AddingGroupMembers: + modalNode = ( + { + setAddGroupMembersRequestState(oldRequestState => { + assert( + oldRequestState !== RequestState.Active, + 'Should not be clearing an active request state' + ); + return RequestState.Inactive; + }); + }} + conversationIdsAlreadyInGroup={ + new Set( + (conversation.memberships || []).map( + membership => membership.member.id + ) + ) + } + groupTitle={conversation.title} + i18n={i18n} + makeRequest={async conversationIds => { + setAddGroupMembersRequestState(RequestState.Active); + + try { + await addMembers(conversationIds); + setModalState(ModalState.NothingOpen); + setAddGroupMembersRequestState(RequestState.Inactive); + } catch (err) { + setAddGroupMembersRequestState(RequestState.InactiveWithError); + } + }} + onClose={() => { + setModalState(ModalState.NothingOpen); + setEditGroupAttributesRequestState(RequestState.Inactive); + }} + requestState={addGroupMembersRequestState} + /> + ); + break; + default: + throw missingCaseError(modalState); + } + return (
= ({ conversation={conversation} i18n={i18n} startEditing={() => { - setIsEditingGroupAttributes(true); + setModalState(ModalState.EditingGroupAttributes); }} /> @@ -141,9 +236,13 @@ export const ConversationDetails: React.ComponentType = ({ ) : null} { + setModalState(ModalState.AddingGroupMembers); + }} /> @@ -200,42 +299,7 @@ export const ConversationDetails: React.ComponentType = ({ onBlockAndDelete={onBlockAndDelete} /> - {isEditingGroupAttributes && ( - - ) => { - setEditGroupAttributesRequestState( - EditGroupAttributesRequestState.Active - ); - - try { - await updateGroupAttributes(options); - setIsEditingGroupAttributes(false); - setEditGroupAttributesRequestState( - EditGroupAttributesRequestState.Inactive - ); - } catch (err) { - setEditGroupAttributesRequestState( - EditGroupAttributesRequestState.InactiveWithError - ); - } - }} - onClose={() => { - setIsEditingGroupAttributes(false); - setEditGroupAttributesRequestState( - EditGroupAttributesRequestState.Inactive - ); - }} - requestState={editGroupAttributesRequestState} - title={conversation.title} - /> - )} + {modalNode}
); }; diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx index 13d748761..880337300 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx @@ -1,7 +1,8 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; +import { isBoolean } from 'lodash'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; @@ -42,9 +43,13 @@ const createMemberships = ( }; const createProps = (overrideProps: Partial): Props => ({ + canAddNewMembers: isBoolean(overrideProps.canAddNewMembers) + ? overrideProps.canAddNewMembers + : false, i18n, memberships: overrideProps.memberships || [], showContactModal: action('showContactModal'), + startAddingNewMembers: action('startAddingNewMembers'), }); story.add('Few', () => { @@ -92,3 +97,11 @@ story.add('None', () => { return ; }); + +story.add('Can add new members', () => { + const memberships = createMemberships(10); + + const props = createProps({ canAddNewMembers: true, memberships }); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx index d947af7fb..d36ac2fc9 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -19,8 +19,10 @@ export type GroupV2Membership = { }; export type Props = { + canAddNewMembers: boolean; memberships: Array; showContactModal: (conversationId: string) => void; + startAddingNewMembers: () => void; i18n: LocalizerType; }; @@ -66,8 +68,10 @@ function sortMemberships( } export const ConversationDetailsMembershipList: React.ComponentType = ({ + canAddNewMembers, memberships, showContactModal, + startAddingNewMembers, i18n, }) => { const [showAllMembers, setShowAllMembers] = React.useState(false); @@ -85,6 +89,15 @@ export const ConversationDetailsMembershipList: React.ComponentType = ({ sortedMemberships.length.toString(), ])} > + {canAddNewMembers && ( + + } + label={i18n('ConversationDetailsMembershipList--add-members')} + onClick={startAddingNewMembers} + /> + )} {sortedMemberships.slice(0, membersToShow).map(({ isAdmin, member }) => ( = ({ avatarPath: externalAvatarPath, i18n, diff --git a/ts/components/conversation/conversation-details/util.ts b/ts/components/conversation/conversation-details/util.ts index 3672694be..48fb187f4 100644 --- a/ts/components/conversation/conversation-details/util.ts +++ b/ts/components/conversation/conversation-details/util.ts @@ -1,8 +1,14 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import classNames from 'classnames'; +export enum RequestState { + Inactive, + InactiveWithError, + Active, +} + export const bemGenerator = (block: string) => ( element: string, modifier?: string | Record diff --git a/ts/components/conversationList/BaseConversationListItem.tsx b/ts/components/conversationList/BaseConversationListItem.tsx index 6f65f1e3a..37580dbaa 100644 --- a/ts/components/conversationList/BaseConversationListItem.tsx +++ b/ts/components/conversationList/BaseConversationListItem.tsx @@ -98,6 +98,11 @@ export const BaseConversationListItem: FunctionComponent = React.memo className={CHECKBOX_CLASS_NAME} disabled={disabled} onChange={onClick} + onKeyDown={event => { + if (onClick && !disabled && event.key === 'Enter') { + onClick(); + } + }} type="checkbox" /> ); diff --git a/ts/components/conversationList/ContactCheckbox.tsx b/ts/components/conversationList/ContactCheckbox.tsx index e28ea7bfc..f8ac6b2ae 100644 --- a/ts/components/conversationList/ContactCheckbox.tsx +++ b/ts/components/conversationList/ContactCheckbox.tsx @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { CSSProperties, FunctionComponent } from 'react'; +import React, { CSSProperties, FunctionComponent, ReactNode } from 'react'; import { BaseConversationListItem } from './BaseConversationListItem'; import { ColorType } from '../../types/Colors'; @@ -11,7 +11,8 @@ import { About } from '../conversation/About'; export enum ContactCheckboxDisabledReason { // We start the enum at 1 because the default starting value of 0 is falsy. - MaximumContactsSelected = 1, + AlreadyAdded = 1, + MaximumContactsSelected, NotCapable, } @@ -67,7 +68,14 @@ export const ContactCheckbox: FunctionComponent = React.memo( /> ); - const messageText = about ? : null; + let messageText: ReactNode; + if (disabledReason === ContactCheckboxDisabledReason.AlreadyAdded) { + messageText = i18n('alreadyAMember'); + } else if (about) { + messageText = ; + } else { + messageText = null; + } const onClickItem = () => { onClick(id, disabledReason); diff --git a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx index 43e8dadef..701daf972 100644 --- a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx +++ b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx @@ -9,7 +9,10 @@ import { ConversationType } from '../../state/ducks/conversations'; import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox'; import { ContactPills } from '../ContactPills'; import { ContactPill } from '../ContactPill'; -import { Alert } from '../Alert'; +import { + AddGroupMemberErrorDialog, + AddGroupMemberErrorDialogMode, +} from '../AddGroupMemberErrorDialog'; import { Button } from '../Button'; import { LocalizerType } from '../../types/Util'; import { @@ -111,35 +114,34 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper< ) => unknown; removeSelectedContact: (conversationId: string) => unknown; }>): ReactChild { - let modalDetails: - | undefined - | { title: string; body: string; onClose: () => void }; + let modalNode: undefined | ReactChild; if (this.isShowingMaximumGroupSizeModal) { - modalDetails = { - title: i18n('chooseGroupMembers__maximum-group-size__title'), - body: i18n('chooseGroupMembers__maximum-group-size__body', [ - this.getMaximumNumberOfContacts().toString(), - ]), - onClose: closeMaximumGroupSizeModal, - }; + modalNode = ( + + ); } else if (this.isShowingRecommendedGroupSizeModal) { - modalDetails = { - title: i18n( - 'chooseGroupMembers__maximum-recommended-group-size__title' - ), - body: i18n('chooseGroupMembers__maximum-recommended-group-size__body', [ - this.getRecommendedMaximumNumberOfContacts().toString(), - ]), - onClose: closeRecommendedGroupSizeModal, - }; + modalNode = ( + + ); } else if (this.cantAddContactForModal) { - modalDetails = { - title: i18n('chooseGroupMembers__cant-add-member__title'), - body: i18n('chooseGroupMembers__cant-add-member__body', [ - this.cantAddContactForModal.title, - ]), - onClose: closeCantAddContactToGroupModal, - }; + modalNode = ( + + ); } return ( @@ -149,7 +151,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper< type="text" ref={focusRef} className="module-left-pane__compose-search-form__input" - placeholder={i18n('newConversationContactSearchPlaceholder')} + placeholder={i18n('contactSearchPlaceholder')} dir="auto" value={this.searchTerm} onChange={onChangeComposeSearchTerm} @@ -178,18 +180,11 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper< {this.getRowCount() ? null : (
- {i18n('newConversationNoContacts')} + {i18n('noContactsFound')}
)} - {modalDetails && ( - - )} + {modalNode} ); } diff --git a/ts/components/leftPane/LeftPaneComposeHelper.tsx b/ts/components/leftPane/LeftPaneComposeHelper.tsx index f232d65e0..0351fdb18 100644 --- a/ts/components/leftPane/LeftPaneComposeHelper.tsx +++ b/ts/components/leftPane/LeftPaneComposeHelper.tsx @@ -90,7 +90,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper< type="text" ref={focusRef} className="module-left-pane__compose-search-form__input" - placeholder={i18n('newConversationContactSearchPlaceholder')} + placeholder={i18n('contactSearchPlaceholder')} dir="auto" value={this.searchTerm} onChange={onChangeComposeSearchTerm} @@ -99,7 +99,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper< {this.getRowCount() ? null : (
- {i18n('newConversationNoContacts')} + {i18n('noContactsFound')}
)} diff --git a/ts/groups.ts b/ts/groups.ts index 7b5f8f3ca..7495f6b31 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -550,6 +550,148 @@ function buildGroupProto( return proto; } +export async function buildAddMembersChange( + conversation: Pick< + ConversationAttributesType, + 'id' | 'publicParams' | 'revision' | 'secretParams' + >, + conversationIds: ReadonlyArray +): Promise { + const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; + + const { id, publicParams, revision, secretParams } = conversation; + + const logId = `groupv2(${id})`; + + if (!publicParams) { + throw new Error( + `buildAddMembersChange/${logId}: attributes were missing publicParams!` + ); + } + if (!secretParams) { + throw new Error( + `buildAddMembersChange/${logId}: attributes were missing secretParams!` + ); + } + + const newGroupVersion = (revision || 0) + 1; + const serverPublicParamsBase64 = window.getServerPublicParams(); + const clientZkProfileCipher = getClientZkProfileOperations( + serverPublicParamsBase64 + ); + const clientZkGroupCipher = getClientZkGroupCipher(secretParams); + + const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); + const ourConversation = window.ConversationController.get(ourConversationId); + const ourUuid = ourConversation?.get('uuid'); + if (!ourUuid) { + throw new Error( + `buildAddMembersChange/${logId}: unable to find our own UUID!` + ); + } + const ourUuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, ourUuid); + + const now = Date.now(); + + const addMembers: Array = []; + const addPendingMembers: Array = []; + + await Promise.all( + conversationIds.map(async conversationId => { + const contact = window.ConversationController.get(conversationId); + if (!contact) { + assert( + false, + `buildAddMembersChange/${logId}: missing local contact, skipping` + ); + return; + } + + const uuid = contact.get('uuid'); + if (!uuid) { + assert(false, `buildAddMembersChange/${logId}: missing UUID; skipping`); + return; + } + + // Refresh our local data to be sure + if ( + !contact.get('capabilities')?.gv2 || + !contact.get('profileKey') || + !contact.get('profileKeyCredential') + ) { + await contact.getProfiles(); + } + + if (!contact.get('capabilities')?.gv2) { + assert( + false, + `buildAddMembersChange/${logId}: member is missing GV2 capability; skipping` + ); + return; + } + + const profileKey = contact.get('profileKey'); + const profileKeyCredential = contact.get('profileKeyCredential'); + + if (!profileKey) { + assert( + false, + `buildAddMembersChange/${logId}: member is missing profile key; skipping` + ); + return; + } + + const member = new window.textsecure.protobuf.Member(); + member.userId = encryptUuid(clientZkGroupCipher, uuid); + member.role = MEMBER_ROLE_ENUM.DEFAULT; + member.joinedAtVersion = newGroupVersion; + + // This is inspired by [Android's equivalent code][0]. + // + // [0]: https://github.com/signalapp/Signal-Android/blob/2be306867539ab1526f0e49d1aa7bd61e783d23f/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java#L152-L174 + if (profileKey && profileKeyCredential) { + member.presentation = createProfileKeyCredentialPresentation( + clientZkProfileCipher, + profileKeyCredential, + secretParams + ); + + const addMemberAction = new window.textsecure.protobuf.GroupChange.Actions.AddMemberAction(); + addMemberAction.added = member; + addMemberAction.joinFromInviteLink = false; + + addMembers.push(addMemberAction); + } else { + const memberPendingProfileKey = new window.textsecure.protobuf.MemberPendingProfileKey(); + memberPendingProfileKey.member = member; + memberPendingProfileKey.addedByUserId = ourUuidCipherTextBuffer; + memberPendingProfileKey.timestamp = now; + + const addPendingMemberAction = new window.textsecure.protobuf.GroupChange.Actions.AddMemberPendingProfileKeyAction(); + addPendingMemberAction.added = memberPendingProfileKey; + + addPendingMembers.push(addPendingMemberAction); + } + }) + ); + + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + if (!addMembers.length && !addPendingMembers.length) { + // This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning + // will be logged. + return undefined; + } + if (addMembers.length) { + actions.addMembers = addMembers; + } + if (addPendingMembers.length) { + actions.addPendingMembers = addPendingMembers; + } + actions.version = newGroupVersion; + + return actions; +} + export async function buildUpdateAttributesChange( conversation: Pick< ConversationAttributesType, diff --git a/ts/groups/toggleSelectedContactForGroupAddition.ts b/ts/groups/toggleSelectedContactForGroupAddition.ts new file mode 100644 index 000000000..4fd8349d2 --- /dev/null +++ b/ts/groups/toggleSelectedContactForGroupAddition.ts @@ -0,0 +1,68 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { without } from 'lodash'; + +export enum OneTimeModalState { + NeverShown, + Showing, + Shown, +} + +export function toggleSelectedContactForGroupAddition( + conversationId: string, + currentState: Readonly<{ + maxGroupSize: number; + maxRecommendedGroupSize: number; + maximumGroupSizeModalState: OneTimeModalState; + numberOfContactsAlreadyInGroup: number; + recommendedGroupSizeModalState: OneTimeModalState; + selectedConversationIds: Array; + }> +): { + maximumGroupSizeModalState: OneTimeModalState; + recommendedGroupSizeModalState: OneTimeModalState; + selectedConversationIds: Array; +} { + const { + maxGroupSize, + maxRecommendedGroupSize, + numberOfContactsAlreadyInGroup, + selectedConversationIds: oldSelectedConversationIds, + } = currentState; + let { + maximumGroupSizeModalState, + recommendedGroupSizeModalState, + } = currentState; + + const selectedConversationIds = without( + oldSelectedConversationIds, + conversationId + ); + const shouldAdd = + selectedConversationIds.length === oldSelectedConversationIds.length; + if (shouldAdd) { + const newExpectedMemberCount = + selectedConversationIds.length + numberOfContactsAlreadyInGroup + 1; + if (newExpectedMemberCount <= maxGroupSize) { + if ( + newExpectedMemberCount === maxGroupSize && + maximumGroupSizeModalState === OneTimeModalState.NeverShown + ) { + maximumGroupSizeModalState = OneTimeModalState.Showing; + } else if ( + newExpectedMemberCount >= maxRecommendedGroupSize && + recommendedGroupSizeModalState === OneTimeModalState.NeverShown + ) { + recommendedGroupSizeModalState = OneTimeModalState.Showing; + } + selectedConversationIds.push(conversationId); + } + } + + return { + selectedConversationIds, + maximumGroupSizeModalState, + recommendedGroupSizeModalState, + }; +} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index dd090c6da..ade463f40 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1716,6 +1716,22 @@ export class ConversationModel extends window.Backbone.Model< }); } + async addMembersV2(conversationIds: ReadonlyArray): Promise { + await this.modifyGroupV2({ + name: 'addMembersV2', + createGroupChange: () => + window.Signal.Groups.buildAddMembersChange( + { + id: this.id, + publicParams: this.get('publicParams'), + revision: this.get('revision'), + secretParams: this.get('secretParams'), + }, + conversationIds + ), + }); + } + async updateGroupAttributesV2( attributes: Readonly<{ avatar?: undefined | ArrayBuffer; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 03e5d5af3..6e356c56c 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -35,6 +35,7 @@ import { getGroupSizeRecommendedLimit, getGroupSizeHardLimit, } from '../../groups/limits'; +import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition'; // State @@ -2273,50 +2274,23 @@ export function reducer( return state; } - const { selectedConversationIds: oldSelectedConversationIds } = composer; - let { - maximumGroupSizeModalState, - recommendedGroupSizeModalState, - } = composer; - const { - conversationId, - maxGroupSize, - maxRecommendedGroupSize, - } = action.payload; - - const selectedConversationIds = without( - oldSelectedConversationIds, - conversationId - ); - const shouldAdd = - selectedConversationIds.length === oldSelectedConversationIds.length; - if (shouldAdd) { - // 1 for you, 1 for the new contact. - const newExpectedMemberCount = selectedConversationIds.length + 2; - if (newExpectedMemberCount > maxGroupSize) { - return state; - } - if ( - newExpectedMemberCount === maxGroupSize && - maximumGroupSizeModalState === OneTimeModalState.NeverShown - ) { - maximumGroupSizeModalState = OneTimeModalState.Showing; - } else if ( - newExpectedMemberCount >= maxRecommendedGroupSize && - recommendedGroupSizeModalState === OneTimeModalState.NeverShown - ) { - recommendedGroupSizeModalState = OneTimeModalState.Showing; - } - selectedConversationIds.push(conversationId); - } - return { ...state, composer: { ...composer, - maximumGroupSizeModalState, - recommendedGroupSizeModalState, - selectedConversationIds, + ...toggleSelectedContactForGroupAddition( + action.payload.conversationId, + { + maxGroupSize: action.payload.maxGroupSize, + maxRecommendedGroupSize: action.payload.maxRecommendedGroupSize, + maximumGroupSizeModalState: composer.maximumGroupSizeModalState, + // We say you're already in the group, even though it hasn't been created yet. + numberOfContactsAlreadyInGroup: 1, + recommendedGroupSizeModalState: + composer.recommendedGroupSizeModalState, + selectedConversationIds: composer.selectedConversationIds, + } + ), }, }; } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 7a2227e88..996614594 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -4,7 +4,6 @@ import memoizee from 'memoizee'; import { fromPairs, isNumber, isString } from 'lodash'; import { createSelector } from 'reselect'; -import Fuse, { FuseOptions } from 'fuse.js'; import { StateType } from '../reducer'; import { @@ -29,6 +28,7 @@ import { PropsDataType as TimelinePropsType } from '../../components/conversatio import { TimelineItemType } from '../../components/conversation/TimelineItem'; import { assert } from '../../util/assert'; import { isConversationUnregistered } from '../../util/isConversationUnregistered'; +import { filterAndSortContacts } from '../../util/filterAndSortContacts'; import { getInteractionMode, @@ -342,14 +342,14 @@ export const getComposerContactSearchTerm = createSelector( ); /** - * This returns contacts for the composer, which isn't just your primary's system - * contacts. It may include false positives, which is better than missing contacts. + * This returns contacts for the composer and group members, which isn't just your primary + * system contacts. It may include false positives, which is better than missing contacts. * * Because it filters unregistered contacts and that's (partially) determined by the * current time, it's possible for this to return stale contacts that have unregistered * if no other conversations change. This should be a rare false positive. */ -const getContacts = createSelector( +export const getContacts = createSelector( getConversationLookup, (conversationLookup: ConversationLookupType): Array => Object.values(conversationLookup).filter( @@ -371,13 +371,6 @@ const getNoteToSelfTitle = createSelector(getIntl, (i18n: LocalizerType) => i18n('noteToSelf').toLowerCase() ); -const COMPOSE_CONTACTS_FUSE_OPTIONS: FuseOptions = { - // A small-but-nonzero threshold lets us match parts of E164s better, and makes the - // search a little more forgiving. - threshold: 0.05, - keys: ['title', 'name', 'e164'], -}; - export const getComposeContacts = createSelector( getNormalizedComposerContactSearchTerm, getContacts, @@ -389,55 +382,21 @@ export const getComposeContacts = createSelector( noteToSelf: ConversationType, noteToSelfTitle: string ): Array => { - let result: Array; - - if (searchTerm.length) { - const fuse = new Fuse( - contacts, - COMPOSE_CONTACTS_FUSE_OPTIONS - ); - result = fuse.search(searchTerm); - if (noteToSelfTitle.includes(searchTerm)) { - result.push(noteToSelf); - } - } else { - result = contacts.concat(); - result.sort((a, b) => collator.compare(a.title, b.title)); + const result: Array = filterAndSortContacts( + contacts, + searchTerm + ); + if (!searchTerm || noteToSelfTitle.includes(searchTerm)) { result.push(noteToSelf); } - return result; } ); -/* - * This returns contacts for the composer when you're picking new group members. It casts - * a wider net than `getContacts`. - */ -const getGroupContacts = createSelector( - getConversationLookup, - (conversationLookup): Array => - Object.values(conversationLookup).filter( - contact => - contact.type === 'direct' && - !contact.isMe && - !contact.isBlocked && - !isConversationUnregistered(contact) - ) -); - -export const getCandidateGroupContacts = createSelector( +export const getCandidateContactsForNewGroup = createSelector( + getContacts, getNormalizedComposerContactSearchTerm, - getGroupContacts, - (searchTerm, contacts): Array => { - if (searchTerm.length) { - return new Fuse( - contacts, - COMPOSE_CONTACTS_FUSE_OPTIONS - ).search(searchTerm); - } - return contacts.concat().sort((a, b) => collator.compare(a.title, b.title)); - } + filterAndSortContacts ); export const getCantAddContactForModal = createSelector( diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 39cf4fb13..1437689f6 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -8,11 +8,15 @@ import { ConversationDetails, StateProps, } from '../../components/conversation/conversation-details/ConversationDetails'; -import { getConversationSelector } from '../selectors/conversations'; +import { + getContacts, + getConversationSelector, +} from '../selectors/conversations'; import { getIntl } from '../selectors/user'; import { MediaItemType } from '../../components/LightboxGallery'; export type SmartConversationDetailsProps = { + addMembers: (conversationIds: ReadonlyArray) => Promise; conversationId: string; hasGroupLink: boolean; loadRecentMediaItems: (limit: number) => void; @@ -46,10 +50,12 @@ const mapStateToProps = ( ? conversation.canEditGroupInfo : false; const isAdmin = Boolean(conversation?.areWeAdmin); + const candidateContactsToAdd = getContacts(state); return { ...props, canEditGroupInfo, + candidateContactsToAdd, conversation, i18n: getIntl(state), isAdmin, diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 28e513a3f..d39abba6b 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -16,7 +16,7 @@ import { ComposerStep, OneTimeModalState } from '../ducks/conversations'; import { getSearchResults, isSearching } from '../selectors/search'; import { getIntl, getRegionCode } from '../selectors/user'; import { - getCandidateGroupContacts, + getCandidateContactsForNewGroup, getCantAddContactForModal, getComposeContacts, getComposeGroupAvatar, @@ -102,7 +102,7 @@ const getModeSpecificProps = ( case ComposerStep.ChooseGroupMembers: return { mode: LeftPaneMode.ChooseGroupMembers, - candidateContacts: getCandidateGroupContacts(state), + candidateContacts: getCandidateContactsForNewGroup(state), cantAddContactForModal: getCantAddContactForModal(state), isShowingRecommendedGroupSizeModal: getRecommendedGroupSizeModalState(state) === diff --git a/ts/test-both/helpers/getDefaultConversation.ts b/ts/test-both/helpers/getDefaultConversation.ts index 47e88b7d5..ee12d15eb 100644 --- a/ts/test-both/helpers/getDefaultConversation.ts +++ b/ts/test-both/helpers/getDefaultConversation.ts @@ -1,7 +1,8 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { v4 as generateUuid } from 'uuid'; +import { sample } from 'lodash'; import { ConversationType } from '../../state/ducks/conversations'; const FIRST_NAMES = [ @@ -310,21 +311,23 @@ const LAST_NAMES = [ 'Jimenez', ]; -export function getRandomTitle(): string { - const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)]; - const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)]; - return `${firstName} ${lastName}`; -} +const getFirstName = (): string => sample(FIRST_NAMES) || 'Test'; +const getLastName = (): string => sample(LAST_NAMES) || 'Test'; export function getDefaultConversation( - overrideProps: Partial + overrideProps: Partial = {} ): ConversationType { + const firstName = getFirstName(); + const lastName = getLastName(); + return { id: generateUuid(), + isGroupV2Capable: true, lastUpdated: Date.now(), markedUnread: Boolean(overrideProps.markedUnread), e164: '+1300555000', - title: getRandomTitle(), + firstName, + title: `${firstName} ${lastName}`, type: 'direct' as const, uuid: generateUuid(), ...overrideProps, diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 06328ca12..5360b6f99 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -13,7 +13,7 @@ import { import { _getConversationComparator, _getLeftPaneLists, - getCandidateGroupContacts, + getCandidateContactsForNewGroup, getCantAddContactForModal, getComposeContacts, getComposeGroupAvatar, @@ -555,7 +555,7 @@ describe('both/state/selectors/conversations', () => { }); }); - describe('#getCandidateGroupContacts', () => { + describe('#getCandidateContactsForNewGroup', () => { const getRootState = (contactSearchTerm = ''): StateType => { const rootState = getEmptyRootState(); return { @@ -574,7 +574,7 @@ describe('both/state/selectors/conversations', () => { }, 'convo-2': { ...getDefaultConversation('convo-2'), - title: 'B. Sorted Second', + title: 'Should be dropped (has no name)', }, 'convo-3': { ...getDefaultConversation('convo-3'), @@ -584,19 +584,17 @@ describe('both/state/selectors/conversations', () => { 'convo-4': { ...getDefaultConversation('convo-4'), isBlocked: true, + name: 'My Name', title: 'Should Be Dropped (blocked)', }, 'convo-5': { ...getDefaultConversation('convo-5'), discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(), + name: 'My Name', title: 'Should Be Dropped (unregistered)', }, 'convo-6': { ...getDefaultConversation('convo-6'), - title: 'D. Sorted Last', - }, - 'convo-7': { - ...getDefaultConversation('convo-7'), discoveredUnregisteredAt: Date.now(), name: 'In System Contacts (and only recently unregistered)', title: 'C. Sorted Third', @@ -623,18 +621,18 @@ describe('both/state/selectors/conversations', () => { it('returns sorted contacts when there is no search term', () => { const state = getRootState(); - const result = getCandidateGroupContacts(state); + const result = getCandidateContactsForNewGroup(state); const ids = result.map(contact => contact.id); - assert.deepEqual(ids, ['convo-1', 'convo-2', 'convo-7', 'convo-6']); + assert.deepEqual(ids, ['convo-1', 'convo-6']); }); it('can search for contacts', () => { const state = getRootState('system contacts'); - const result = getCandidateGroupContacts(state); + const result = getCandidateContactsForNewGroup(state); const ids = result.map(contact => contact.id); - assert.deepEqual(ids, ['convo-1', 'convo-7']); + assert.deepEqual(ids, ['convo-1', 'convo-6']); }); }); diff --git a/ts/test-both/util/filterAndSortContacts_test.ts b/ts/test-both/util/filterAndSortContacts_test.ts new file mode 100644 index 000000000..870d77356 --- /dev/null +++ b/ts/test-both/util/filterAndSortContacts_test.ts @@ -0,0 +1,41 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { getDefaultConversation } from '../helpers/getDefaultConversation'; + +import { filterAndSortContacts } from '../../util/filterAndSortContacts'; + +describe('filterAndSortContacts', () => { + const conversations = [ + getDefaultConversation({ + title: '+16505551234', + firstName: undefined, + profileName: undefined, + }), + getDefaultConversation({ title: 'Carlos Santana' }), + getDefaultConversation({ title: 'Aaron Aardvark' }), + getDefaultConversation({ title: 'Belinda Beetle' }), + getDefaultConversation({ title: 'Belinda Zephyr' }), + ]; + + it('without a search term, sorts conversations by title', () => { + const titles = filterAndSortContacts(conversations, '').map( + contact => contact.title + ); + assert.deepEqual(titles, [ + '+16505551234', + 'Aaron Aardvark', + 'Belinda Beetle', + 'Belinda Zephyr', + 'Carlos Santana', + ]); + }); + + it('filters conversations a search terms', () => { + const titles = filterAndSortContacts(conversations, 'belind').map( + contact => contact.title + ); + assert.deepEqual(titles, ['Belinda Beetle', 'Belinda Zephyr']); + }); +}); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 1543cfbad..dc8a81c08 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -1904,7 +1904,7 @@ describe('both/state/ducks/conversations', () => { const action = getAction(uuid(), state); const result = reducer(state, action); - assert.strictEqual(result, state); + assert.deepEqual(result, state); }); it('defaults the maximum group size to 1001 if the recommended maximum is smaller', () => { diff --git a/ts/util/filterAndSortContacts.ts b/ts/util/filterAndSortContacts.ts new file mode 100644 index 000000000..ddf398b0c --- /dev/null +++ b/ts/util/filterAndSortContacts.ts @@ -0,0 +1,27 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import Fuse, { FuseOptions } from 'fuse.js'; + +import { ConversationType } from '../state/ducks/conversations'; + +const FUSE_OPTIONS: FuseOptions = { + // A small-but-nonzero threshold lets us match parts of E164s better, and makes the + // search a little more forgiving. + threshold: 0.05, + keys: ['title', 'name', 'e164'], +}; + +const collator = new Intl.Collator(); + +export function filterAndSortContacts( + contacts: ReadonlyArray, + searchTerm: string +): Array { + if (searchTerm.length) { + return new Fuse(contacts, FUSE_OPTIONS).search( + searchTerm + ); + } + return contacts.concat().sort((a, b) => collator.compare(a.title, b.title)); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 8ac070eee..cd58f5419 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -15077,11 +15077,20 @@ "updated": "2019-07-31T00:19:18.696Z", "reasonDetail": "Timeline needs to interact with its child List directly" }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.js", + "line": " const inputRef = react_1.useRef(null);", + "lineNumber": 41, + "reasonCategory": "usageTrusted", + "updated": "2021-03-11T20:49:17.292Z", + "reasonDetail": "Used to focus an input." + }, { "rule": "React-useRef", "path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js", "line": " const startingTitleRef = react_1.useRef(externalTitle);", - "lineNumber": 42, + "lineNumber": 37, "reasonCategory": "usageTrusted", "updated": "2021-03-05T22:52:40.572Z", "reasonDetail": "Doesn't interact with the DOM." @@ -15090,7 +15099,7 @@ "rule": "React-useRef", "path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js", "line": " const startingAvatarPathRef = react_1.useRef(externalAvatarPath);", - "lineNumber": 43, + "lineNumber": 38, "reasonCategory": "usageTrusted", "updated": "2021-03-05T22:52:40.572Z", "reasonDetail": "Doesn't interact with the DOM." diff --git a/ts/util/makeLookup.ts b/ts/util/makeLookup.ts index a76b2d89f..264b578c6 100644 --- a/ts/util/makeLookup.ts +++ b/ts/util/makeLookup.ts @@ -1,8 +1,8 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only export function makeLookup( - items: Array, + items: ReadonlyArray, key: keyof T ): Record { return (items || []).reduce((lookup, item) => { diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 555e3c1f8..6587ae647 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -2887,6 +2887,7 @@ Whisper.ConversationView = Whisper.View.extend({ ACCESS_ENUM.UNSATISFIABLE; const props = { + addMembers: conversation.addMembersV2.bind(conversation), conversationId: conversation.get('id'), hasGroupLink, loadRecentMediaItems: this.loadRecentMediaItems.bind(this),