New Group administration: Add users

This commit is contained in:
Evan Hahn 2021-03-11 15:29:31 -06:00 committed by Josh Perez
parent e81c18e84c
commit b81a52bbdd
43 changed files with 1789 additions and 277 deletions

View File

@ -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$” cant be added to the group because theyre using an old version of Signal. You can add them to the group after theyve updated Signal.",
"message": "\"$name$\" cant be added to the group because theyre using an old version of Signal. You can add them to the group after theyve 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"

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}
}
}

View File

@ -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 <ConversationList> 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;
}
}
}

View File

@ -21,7 +21,7 @@
&__title {
@include font-body-1-bold;
margin: 0;
margin: 0 0 1em 0;
padding: 0;
}

View File

@ -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 {

View File

@ -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';

View File

@ -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", () => (
<AddGroupMemberErrorDialog
{...defaultProps}
mode={AddGroupMemberErrorDialogMode.CantAddContact}
contact={{
name: 'Foo Bar',
title: 'Foo Bar',
}}
/>
));
story.add('Maximum group size', () => (
<AddGroupMemberErrorDialog
{...defaultProps}
mode={AddGroupMemberErrorDialogMode.MaximumGroupSize}
maximumNumberOfContacts={123}
/>
));
story.add('Maximum recommended group size', () => (
<AddGroupMemberErrorDialog
{...defaultProps}
mode={AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize}
recommendedMaximumNumberOfContacts={123}
/>
));

View File

@ -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<PropsType> = 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 = (
<Intl
i18n={i18n}
id="chooseGroupMembers__cant-add-member__body"
components={[
<ContactName
key="name"
name={contact.name}
profileName={contact.profileName}
phoneNumber={contact.phoneNumber}
title={contact.title}
i18n={i18n}
/>,
]}
/>
);
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 <Alert body={body} i18n={i18n} onClose={onClose} title={title} />;
};

View File

@ -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', () => (
<Alert
{...defaultProps}
title="Hello world"
body="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus."
/>
));
story.add('Body is a ReactNode', () => (
<Alert
{...defaultProps}
title="Hello world"
body={
<>
<span style={{ color: 'red' }}>Hello</span>{' '}
<span style={{ color: 'green', fontWeight: 'bold' }}>world</span>!
</>
}
/>
));

View File

@ -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;
};

View File

@ -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<ConversationListItemPropsType> = [
'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<Row>): 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,
},
])}
/>
));

View File

@ -380,8 +380,9 @@ export const LeftPane: React.FC<PropsType> = ({
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);

View File

@ -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<typeof AddGroupMembersModal>;
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
candidateContacts: allCandidateContacts,
clearRequestError: action('clearRequestError'),
conversationIdsAlreadyInGroup: new Set(),
groupTitle: 'Tahoe Trip',
i18n,
onClose: action('onClose'),
makeRequest: async (conversationIds: ReadonlyArray<string>) => {
action('onMakeRequest')(conversationIds);
},
requestState: RequestState.Inactive,
...overrideProps,
});
story.add('Default', () => <AddGroupMembersModal {...createProps()} />);
story.add('Only 3 contacts', () => (
<AddGroupMembersModal
{...createProps({
candidateContacts: allCandidateContacts.slice(0, 3),
})}
/>
));
story.add('No candidate contacts', () => (
<AddGroupMembersModal
{...createProps({
candidateContacts: [],
})}
/>
));
story.add('Everyone already added', () => (
<AddGroupMembersModal
{...createProps({
conversationIdsAlreadyInGroup: new Set(
allCandidateContacts.map(contact => contact.id)
),
})}
/>
));
story.add('Request fails after 1 second', () => {
const Wrapper = () => {
const [requestState, setRequestState] = useState(RequestState.Inactive);
return (
<AddGroupMembersModal
{...createProps({
clearRequestError: () => {
setRequestState(RequestState.Inactive);
},
makeRequest: async () => {
setRequestState(RequestState.Active);
await sleep(1000);
setRequestState(RequestState.InactiveWithError);
},
requestState,
})}
/>
);
};
return <Wrapper />;
});

View File

@ -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<ConversationType>;
clearRequestError: () => void;
conversationIdsAlreadyInGroup: Set<string>;
groupTitle: string;
i18n: LocalizerType;
makeRequest: (conversationIds: ReadonlyArray<string>) => Promise<void>;
onClose: () => void;
requestState: RequestState;
};
enum Stage {
ChoosingContacts,
ConfirmingAdds,
}
type StateType = {
cantAddContactForModal: undefined | ConversationType;
maximumGroupSizeModalState: OneTimeModalState;
recommendedGroupSizeModalState: OneTimeModalState;
searchTerm: string;
selectedConversationIds: Array<string>;
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 };
// `<ConversationDetails>` 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<StateType>,
action: Readonly<Action>
): 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<PropsType> = ({
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 (
<AddGroupMemberErrorDialog
contact={cantAddContactForModal}
i18n={i18n}
mode={AddGroupMemberErrorDialogMode.CantAddContact}
onClose={() => {
dispatch({
type: ActionType.SetCantAddContactForModal,
contact: undefined,
});
}}
/>
);
}
if (maximumGroupSizeModalState === OneTimeModalState.Showing) {
return (
<AddGroupMemberErrorDialog
i18n={i18n}
maximumNumberOfContacts={maxGroupSize}
mode={AddGroupMemberErrorDialogMode.MaximumGroupSize}
onClose={() => {
dispatch({ type: ActionType.CloseMaximumGroupSizeModal });
}}
/>
);
}
if (recommendedGroupSizeModalState === OneTimeModalState.Showing) {
return (
<AddGroupMemberErrorDialog
i18n={i18n}
mode={AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize}
onClose={() => {
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<ConversationType>
) => {
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 (
<ChooseGroupMembersModal
candidateContacts={candidateContacts}
confirmAdds={confirmAdds}
contactLookup={contactLookup}
conversationIdsAlreadyInGroup={conversationIdsAlreadyInGroup}
i18n={i18n}
maxGroupSize={maxGroupSize}
onClose={onClose}
removeSelectedContact={removeSelectedContact}
searchTerm={searchTerm}
selectedContacts={selectedContacts}
setCantAddContactForModal={setCantAddContactForModal}
setSearchTerm={setSearchTerm}
toggleSelectedContact={toggleSelectedContact}
/>
);
}
case Stage.ConfirmingAdds: {
const onCloseConfirmationDialog = () => {
dispatch({ type: ActionType.ReturnToContactChooser });
clearRequestError();
};
return (
<ConfirmAdditionsModal
groupTitle={groupTitle}
i18n={i18n}
makeRequest={() => {
makeRequest(selectedConversationIds);
}}
onClose={onCloseConfirmationDialog}
requestState={requestState}
selectedContacts={selectedContacts}
/>
);
}
default:
throw missingCaseError(stage);
}
};
function getRecommendedMaximumNumberOfContacts(): number {
return getGroupSizeRecommendedLimit(151);
}
function getMaximumNumberOfContacts(): number {
return getGroupSizeHardLimit(1001);
}

View File

@ -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<ConversationType>;
confirmAdds: () => void;
contactLookup: Record<string, ConversationType>;
conversationIdsAlreadyInGroup: Set<string>;
i18n: LocalizerType;
maxGroupSize: number;
onClose: () => void;
removeSelectedContact: (_: string) => void;
searchTerm: string;
selectedContacts: ReadonlyArray<ConversationType>;
setCantAddContactForModal: (
_: Readonly<undefined | ConversationType>
) => void;
setSearchTerm: (_: string) => void;
toggleSelectedContact: (conversationId: string) => void;
};
export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
candidateContacts,
confirmAdds,
contactLookup,
conversationIdsAlreadyInGroup,
i18n,
maxGroupSize,
onClose,
removeSelectedContact,
searchTerm,
selectedContacts,
setCantAddContactForModal,
setSearchTerm,
toggleSelectedContact,
}) => {
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(
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 (
<ModalHost onClose={onClose}>
<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>
<input
type="text"
className="module-AddGroupMembersModal__search-input"
disabled={candidateContacts.length === 0}
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
onKeyDown={event => {
if (canContinue && event.key === 'Enter') {
confirmAdds();
}
}}
ref={inputRef}
value={searchTerm}
/>
{Boolean(selectedContacts.length) && (
<ContactPills>
{selectedContacts.map(contact => (
<ContactPill
key={contact.id}
avatarPath={contact.avatarPath}
color={contact.color}
firstName={contact.firstName}
i18n={i18n}
id={contact.id}
name={contact.name}
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
title={contact.title}
onClickRemove={() => {
removeSelectedContact(contact.id);
}}
/>
))}
</ContactPills>
)}
{candidateContacts.length ? (
<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}
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;
case ContactCheckboxDisabledReason.NotCapable: {
const contact = getOwn(contactLookup, conversationId);
assert(
contact,
'Contact was not in lookup; not showing modal'
);
setCantAddContactForModal(contact);
break;
}
default:
throw missingCaseError(disabledReason);
}
}}
onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={shouldNeverBeCalled}
/>
</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>
);
};
function shouldNeverBeCalled(..._args: ReadonlyArray<unknown>): unknown {
assert(false, 'This should never be called. Doing nothing');
}

View File

@ -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<ConversationType>;
};
export const ConfirmAdditionsModal: FunctionComponent<PropsType> = ({
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 = <Emojify text={groupTitle} />;
let headerText: ReactNode;
if (selectedContacts.length === 1) {
headerText = (
<Intl
i18n={i18n}
id="AddGroupMembersModal--confirm-title--one"
components={{
person: (
<ContactName
profileName={firstContact.profileName}
title={firstContact.title}
i18n={i18n}
/>
),
group: groupTitleNode,
}}
/>
);
} else {
headerText = (
<Intl
i18n={i18n}
id="AddGroupMembersModal--confirm-title--many"
components={{
count: selectedContacts.length.toString(),
group: groupTitleNode,
}}
/>
);
}
let buttonContents: ReactNode;
if (requestState === RequestState.Active) {
buttonContents = (
<Spinner size="20px" svgSize="small" direction="on-avatar" />
);
} else if (selectedContacts.length === 1) {
buttonContents = i18n('AddGroupMembersModal--confirm-button--one');
} else {
buttonContents = i18n('AddGroupMembersModal--confirm-button--many');
}
return (
<ModalHost onClose={onClose}>
<div className="module-AddGroupMembersModal module-AddGroupMembersModal--confirm-adds">
<h1 className="module-AddGroupMembersModal__header">{headerText}</h1>
{requestState === RequestState.InactiveWithError && (
<div className="module-AddGroupMembersModal__error-message">
{i18n('updateGroupAttributes__error-message')}
</div>
)}
<div className="module-AddGroupMembersModal__button-container">
<Button onClick={onClose} variant={ButtonVariant.Secondary}>
{i18n('cancel')}
</Button>
<Button
disabled={requestState === RequestState.Active}
onClick={makeRequest}
variant={ButtonVariant.Primary}
>
{buttonContents}
</Button>
</div>
</div>
</ModalHost>
);
};

View File

@ -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,

View File

@ -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<string>) => Promise<void>;
canEditGroupInfo: boolean;
candidateContactsToAdd: Array<ConversationType>;
conversation?: ConversationType;
hasGroupLink: boolean;
i18n: LocalizerType;
@ -53,7 +62,9 @@ export type StateProps = {
export type Props = StateProps;
export const ConversationDetails: React.ComponentType<Props> = ({
addMembers,
canEditGroupInfo,
candidateContactsToAdd,
conversation,
hasGroupLink,
i18n,
@ -70,15 +81,17 @@ export const ConversationDetails: React.ComponentType<Props> = ({
onBlockAndDelete,
onDelete,
}) => {
const [isEditingGroupAttributes, setIsEditingGroupAttributes] = useState(
false
const [modalState, setModalState] = useState<ModalState>(
ModalState.NothingOpen
);
const [
editGroupAttributesRequestState,
setEditGroupAttributesRequestState,
] = useState<EditGroupAttributesRequestState>(
EditGroupAttributesRequestState.Inactive
);
] = useState<RequestState>(RequestState.Inactive);
const [
addGroupMembersRequestState,
setAddGroupMembersRequestState,
] = useState<RequestState>(RequestState.Inactive);
const updateExpireTimer = (event: React.ChangeEvent<HTMLSelectElement>) => {
setDisappearingMessages(parseInt(event.target.value, 10));
@ -94,6 +107,88 @@ export const ConversationDetails: React.ComponentType<Props> = ({
const invitesCount =
pendingMemberships.length + pendingApprovalMemberships.length;
let modalNode: ReactNode;
switch (modalState) {
case ModalState.NothingOpen:
modalNode = undefined;
break;
case ModalState.EditingGroupAttributes:
modalNode = (
<EditConversationAttributesModal
avatarPath={conversation.avatarPath}
i18n={i18n}
makeRequest={async (
options: Readonly<{
avatar?: undefined | ArrayBuffer;
title?: string;
}>
) => {
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 = (
<AddGroupMembersModal
candidateContacts={candidateContactsToAdd}
clearRequestError={() => {
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 (
<div className="conversation-details-panel">
<ConversationDetailsHeader
@ -101,7 +196,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
conversation={conversation}
i18n={i18n}
startEditing={() => {
setIsEditingGroupAttributes(true);
setModalState(ModalState.EditingGroupAttributes);
}}
/>
@ -141,9 +236,13 @@ export const ConversationDetails: React.ComponentType<Props> = ({
) : null}
<ConversationDetailsMembershipList
canAddNewMembers={canEditGroupInfo}
i18n={i18n}
showContactModal={showContactModal}
memberships={conversation.memberships || []}
showContactModal={showContactModal}
startAddingNewMembers={() => {
setModalState(ModalState.AddingGroupMembers);
}}
/>
<PanelSection>
@ -200,42 +299,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
onBlockAndDelete={onBlockAndDelete}
/>
{isEditingGroupAttributes && (
<EditConversationAttributesModal
avatarPath={conversation.avatarPath}
i18n={i18n}
makeRequest={async (
options: Readonly<{
avatar?: undefined | ArrayBuffer;
title?: string;
}>
) => {
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}
</div>
);
};

View File

@ -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>): 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 <ConversationDetailsMembershipList {...props} />;
});
story.add('Can add new members', () => {
const memberships = createMemberships(10);
const props = createProps({ canAddNewMembers: true, memberships });
return <ConversationDetailsMembershipList {...props} />;
});

View File

@ -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<GroupV2Membership>;
showContactModal: (conversationId: string) => void;
startAddingNewMembers: () => void;
i18n: LocalizerType;
};
@ -66,8 +68,10 @@ function sortMemberships(
}
export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
canAddNewMembers,
memberships,
showContactModal,
startAddingNewMembers,
i18n,
}) => {
const [showAllMembers, setShowAllMembers] = React.useState<boolean>(false);
@ -85,6 +89,15 @@ export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
sortedMemberships.length.toString(),
])}
>
{canAddNewMembers && (
<PanelRow
icon={
<div className="module-conversation-details-membership-list__add-members-icon" />
}
label={i18n('ConversationDetailsMembershipList--add-members')}
onClick={startAddingNewMembers}
/>
)}
{sortedMemberships.slice(0, membersToShow).map(({ isAdmin, member }) => (
<PanelRow
key={member.id}

View File

@ -8,10 +8,8 @@ import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import {
EditConversationAttributesModal,
RequestState,
} from './EditConversationAttributesModal';
import { EditConversationAttributesModal } from './EditConversationAttributesModal';
import { RequestState } from './util';
const i18n = setupI18n('en', enMessages);

View File

@ -18,6 +18,7 @@ import { Spinner } from '../../Spinner';
import { GroupTitleInput } from '../../GroupTitleInput';
import * as log from '../../../logging/log';
import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer';
import { RequestState } from './util';
const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
@ -35,12 +36,6 @@ type PropsType = {
title: string;
};
export enum RequestState {
Inactive,
InactiveWithError,
Active,
}
export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
avatarPath: externalAvatarPath,
i18n,

View File

@ -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<string, boolean>

View File

@ -98,6 +98,11 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
className={CHECKBOX_CLASS_NAME}
disabled={disabled}
onChange={onClick}
onKeyDown={event => {
if (onClick && !disabled && event.key === 'Enter') {
onClick();
}
}}
type="checkbox"
/>
);

View File

@ -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<PropsType> = React.memo(
/>
);
const messageText = about ? <About className="" text={about} /> : null;
let messageText: ReactNode;
if (disabledReason === ContactCheckboxDisabledReason.AlreadyAdded) {
messageText = i18n('alreadyAMember');
} else if (about) {
messageText = <About className="" text={about} />;
} else {
messageText = null;
}
const onClickItem = () => {
onClick(id, disabledReason);

View File

@ -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 = (
<AddGroupMemberErrorDialog
i18n={i18n}
maximumNumberOfContacts={this.getMaximumNumberOfContacts()}
mode={AddGroupMemberErrorDialogMode.MaximumGroupSize}
onClose={closeMaximumGroupSizeModal}
/>
);
} 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 = (
<AddGroupMemberErrorDialog
i18n={i18n}
recommendedMaximumNumberOfContacts={this.getRecommendedMaximumNumberOfContacts()}
mode={AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize}
onClose={closeRecommendedGroupSizeModal}
/>
);
} 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 = (
<AddGroupMemberErrorDialog
i18n={i18n}
contact={this.cantAddContactForModal}
mode={AddGroupMemberErrorDialogMode.CantAddContact}
onClose={closeCantAddContactToGroupModal}
/>
);
}
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 : (
<div className="module-left-pane__compose-no-contacts">
{i18n('newConversationNoContacts')}
{i18n('noContactsFound')}
</div>
)}
{modalDetails && (
<Alert
body={modalDetails.body}
i18n={i18n}
onClose={modalDetails.onClose}
title={modalDetails.title}
/>
)}
{modalNode}
</>
);
}

View File

@ -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 : (
<div className="module-left-pane__compose-no-contacts">
{i18n('newConversationNoContacts')}
{i18n('noContactsFound')}
</div>
)}
</>

View File

@ -550,6 +550,148 @@ function buildGroupProto(
return proto;
}
export async function buildAddMembersChange(
conversation: Pick<
ConversationAttributesType,
'id' | 'publicParams' | 'revision' | 'secretParams'
>,
conversationIds: ReadonlyArray<string>
): Promise<undefined | GroupChangeClass.Actions> {
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<GroupChangeClass.Actions.AddMemberAction> = [];
const addPendingMembers: Array<GroupChangeClass.Actions.AddMemberPendingProfileKeyAction> = [];
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,

View File

@ -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<string>;
}>
): {
maximumGroupSizeModalState: OneTimeModalState;
recommendedGroupSizeModalState: OneTimeModalState;
selectedConversationIds: Array<string>;
} {
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,
};
}

View File

@ -1716,6 +1716,22 @@ export class ConversationModel extends window.Backbone.Model<
});
}
async addMembersV2(conversationIds: ReadonlyArray<string>): Promise<void> {
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;

View File

@ -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,
}
),
},
};
}

View File

@ -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<ConversationType> =>
Object.values(conversationLookup).filter(
@ -371,13 +371,6 @@ const getNoteToSelfTitle = createSelector(getIntl, (i18n: LocalizerType) =>
i18n('noteToSelf').toLowerCase()
);
const COMPOSE_CONTACTS_FUSE_OPTIONS: FuseOptions<ConversationType> = {
// 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<ConversationType> => {
let result: Array<ConversationType>;
if (searchTerm.length) {
const fuse = new Fuse<ConversationType>(
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<ConversationType> = 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<ConversationType> =>
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<ConversationType> => {
if (searchTerm.length) {
return new Fuse<ConversationType>(
contacts,
COMPOSE_CONTACTS_FUSE_OPTIONS
).search(searchTerm);
}
return contacts.concat().sort((a, b) => collator.compare(a.title, b.title));
}
filterAndSortContacts
);
export const getCantAddContactForModal = createSelector(

View File

@ -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<string>) => Promise<void>;
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,

View File

@ -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) ===

View File

@ -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<ConversationType>
overrideProps: Partial<ConversationType> = {}
): 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,

View File

@ -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']);
});
});

View File

@ -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']);
});
});

View File

@ -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', () => {

View File

@ -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<ConversationType> = {
// 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<ConversationType>,
searchTerm: string
): Array<ConversationType> {
if (searchTerm.length) {
return new Fuse<ConversationType>(contacts, FUSE_OPTIONS).search(
searchTerm
);
}
return contacts.concat().sort((a, b) => collator.compare(a.title, b.title));
}

View File

@ -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."

View File

@ -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<T>(
items: Array<T>,
items: ReadonlyArray<T>,
key: keyof T
): Record<string, T> {
return (items || []).reduce((lookup, item) => {

View File

@ -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),