New Group administration: Add users
This commit is contained in:
parent
e81c18e84c
commit
b81a52bbdd
43 changed files with 1789 additions and 277 deletions
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
97
stylesheets/components/AddGroupMembersModal.scss
Normal file
97
stylesheets/components/AddGroupMembersModal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
&__title {
|
||||
@include font-body-1-bold;
|
||||
margin: 0;
|
||||
margin: 0 0 1em 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
50
ts/components/AddGroupMemberErrorDialog.stories.tsx
Normal file
50
ts/components/AddGroupMemberErrorDialog.stories.tsx
Normal 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}
|
||||
/>
|
||||
));
|
90
ts/components/AddGroupMemberErrorDialog.tsx
Normal file
90
ts/components/AddGroupMemberErrorDialog.tsx
Normal 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} />;
|
||||
};
|
41
ts/components/Alert.stories.tsx
Normal file
41
ts/components/Alert.stories.tsx
Normal 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>!
|
||||
</>
|
||||
}
|
||||
/>
|
||||
));
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
])}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 />;
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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');
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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} />;
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
142
ts/groups.ts
142
ts/groups.ts
|
@ -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,
|
||||
|
|
68
ts/groups/toggleSelectedContactForGroupAddition.ts
Normal file
68
ts/groups/toggleSelectedContactForGroupAddition.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) ===
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
41
ts/test-both/util/filterAndSortContacts_test.ts
Normal file
41
ts/test-both/util/filterAndSortContacts_test.ts
Normal 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']);
|
||||
});
|
||||
});
|
|
@ -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', () => {
|
||||
|
|
27
ts/util/filterAndSortContacts.ts
Normal file
27
ts/util/filterAndSortContacts.ts
Normal 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));
|
||||
}
|
|
@ -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."
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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),
|
||||
|
|
Loading…
Reference in a new issue