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",
|
"message": "New conversation",
|
||||||
"description": "Label for header when starting a new conversation"
|
"description": "Label for header when starting a new conversation"
|
||||||
},
|
},
|
||||||
"newConversationContactSearchPlaceholder": {
|
"contactSearchPlaceholder": {
|
||||||
"message": "Search by name or phone number",
|
"message": "Search by name or phone number",
|
||||||
"description": "Placeholder to use when searching for contacts in the composer"
|
"description": "Placeholder to use when searching for contacts in the composer"
|
||||||
},
|
},
|
||||||
"newConversationNoContacts": {
|
"noContactsFound": {
|
||||||
"message": "No contacts found",
|
"message": "No contacts found",
|
||||||
"description": "Label shown when there are no contacts to compose to"
|
"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"
|
"description": "Shown in the alert when you try to add someone who can't be added to a group"
|
||||||
},
|
},
|
||||||
"chooseGroupMembers__cant-add-member__body": {
|
"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",
|
"description": "Shown in the alert when you try to add someone who can't be added to a group",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"max": {
|
"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": {
|
"ConversationDetailsMembershipList--show-all": {
|
||||||
"message": "See all",
|
"message": "See all",
|
||||||
"description": "This is a button on the conversation details to show all members"
|
"description": "This is a button on the conversation details to show all members"
|
||||||
|
@ -5027,6 +5031,50 @@
|
||||||
"message": "Learn more",
|
"message": "Learn more",
|
||||||
"description": "When creating a new group and inviting users, this is shown in the dialog"
|
"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": {
|
"createNewGroupButton": {
|
||||||
"message": "New group",
|
"message": "New group",
|
||||||
"description": "The text of the button to create new groups"
|
"description": "The text of the button to create new groups"
|
||||||
|
@ -5043,6 +5091,10 @@
|
||||||
"message": "Cannot select contact",
|
"message": "Cannot select contact",
|
||||||
"description": "The label for contact checkboxes that are disabled"
|
"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": {
|
"MessageAudio--play": {
|
||||||
"message": "Play audio attachment",
|
"message": "Play audio attachment",
|
||||||
"description": "Aria label for audio attachment's Play button"
|
"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.)
|
border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.)
|
||||||
padding: 7px 14px;
|
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 {
|
&__leave-group {
|
||||||
color: $color-accent-red;
|
color: $color-accent-red;
|
||||||
}
|
}
|
||||||
|
@ -7269,11 +7301,13 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled:not(:checked) {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:checked {
|
&:checked {
|
||||||
|
$icon: '../images/icons/v2/check-24.svg';
|
||||||
|
|
||||||
background: $ultramarine-ui-light;
|
background: $ultramarine-ui-light;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -7282,10 +7316,21 @@ button.module-image__border-overlay:focus {
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
@include color-svg('../images/icons/v2/check-24.svg', $color-white);
|
@include color-svg($icon, $color-white);
|
||||||
width: 13px;
|
width: 13px;
|
||||||
height: 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 {
|
&__title {
|
||||||
@include font-body-1-bold;
|
@include font-body-1-bold;
|
||||||
margin: 0;
|
margin: 0 0 1em 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,53 +2,10 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
.module-EditConversationAttributesModal {
|
.module-EditConversationAttributesModal {
|
||||||
@include popper-shadow();
|
@include modal-reset;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__close-button {
|
&__close-button {
|
||||||
@include button-reset;
|
@include modal-close-button;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
@import 'options';
|
@import 'options';
|
||||||
|
|
||||||
// New style: components
|
// New style: components
|
||||||
|
@import './components/AddGroupMembersModal.scss';
|
||||||
@import './components/Alert.scss';
|
@import './components/Alert.scss';
|
||||||
@import './components/AvatarInput.scss';
|
@import './components/AvatarInput.scss';
|
||||||
@import './components/Button.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
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { FunctionComponent } from 'react';
|
import React, { FunctionComponent, ReactNode } from 'react';
|
||||||
|
|
||||||
import { LocalizerType } from '../types/Util';
|
import { LocalizerType } from '../types/Util';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
|
@ -9,7 +9,7 @@ import { ModalHost } from './ModalHost';
|
||||||
|
|
||||||
type PropsType = {
|
type PropsType = {
|
||||||
title?: string;
|
title?: string;
|
||||||
body: string;
|
body: ReactNode;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
MessageStatuses,
|
MessageStatuses,
|
||||||
} from './conversationList/ConversationListItem';
|
} from './conversationList/ConversationListItem';
|
||||||
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
||||||
|
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
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',
|
'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso',
|
||||||
type: 'direct',
|
type: 'direct',
|
||||||
},
|
},
|
||||||
|
getDefaultConversation(),
|
||||||
];
|
];
|
||||||
|
|
||||||
const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
|
const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
|
||||||
|
@ -204,6 +206,12 @@ story.add('Contact checkboxes: disabled', () => (
|
||||||
isChecked: true,
|
isChecked: true,
|
||||||
disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected,
|
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:
|
case undefined:
|
||||||
toggleConversationInChooseMembers(conversationId);
|
toggleConversationInChooseMembers(conversationId);
|
||||||
break;
|
break;
|
||||||
|
case ContactCheckboxDisabledReason.AlreadyAdded:
|
||||||
case ContactCheckboxDisabledReason.MaximumContactsSelected:
|
case ContactCheckboxDisabledReason.MaximumContactsSelected:
|
||||||
// This is a no-op.
|
// These are no-ops.
|
||||||
break;
|
break;
|
||||||
case ContactCheckboxDisabledReason.NotCapable:
|
case ContactCheckboxDisabledReason.NotCapable:
|
||||||
cantAddContactToGroup(conversationId);
|
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 { storiesOf } from '@storybook/react';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { times } from 'lodash';
|
||||||
|
|
||||||
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
||||||
import enMessages from '../../../../_locales/en/messages.json';
|
import enMessages from '../../../../_locales/en/messages.json';
|
||||||
|
@ -47,7 +48,11 @@ const conversation: ConversationType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const createProps = (hasGroupLink = false): Props => ({
|
const createProps = (hasGroupLink = false): Props => ({
|
||||||
|
addMembers: async () => {
|
||||||
|
action('addMembers');
|
||||||
|
},
|
||||||
canEditGroupInfo: false,
|
canEditGroupInfo: false,
|
||||||
|
candidateContactsToAdd: times(10, () => getDefaultConversation()),
|
||||||
conversation,
|
conversation,
|
||||||
hasGroupLink,
|
hasGroupLink,
|
||||||
i18n,
|
i18n,
|
||||||
|
|
|
@ -1,30 +1,39 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 { ConversationType } from '../../../state/ducks/conversations';
|
||||||
|
import { assert } from '../../../util/assert';
|
||||||
import {
|
import {
|
||||||
ExpirationTimerOptions,
|
ExpirationTimerOptions,
|
||||||
TimerOption,
|
TimerOption,
|
||||||
} from '../../../util/ExpirationTimerOptions';
|
} from '../../../util/ExpirationTimerOptions';
|
||||||
import { LocalizerType } from '../../../types/Util';
|
import { LocalizerType } from '../../../types/Util';
|
||||||
import { MediaItemType } from '../../LightboxGallery';
|
import { MediaItemType } from '../../LightboxGallery';
|
||||||
|
import { missingCaseError } from '../../../util/missingCaseError';
|
||||||
|
|
||||||
import { PanelRow } from './PanelRow';
|
import { PanelRow } from './PanelRow';
|
||||||
import { PanelSection } from './PanelSection';
|
import { PanelSection } from './PanelSection';
|
||||||
|
import { AddGroupMembersModal } from './AddGroupMembersModal';
|
||||||
import { ConversationDetailsActions } from './ConversationDetailsActions';
|
import { ConversationDetailsActions } from './ConversationDetailsActions';
|
||||||
import { ConversationDetailsHeader } from './ConversationDetailsHeader';
|
import { ConversationDetailsHeader } from './ConversationDetailsHeader';
|
||||||
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
|
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
|
||||||
import { ConversationDetailsMediaList } from './ConversationDetailsMediaList';
|
import { ConversationDetailsMediaList } from './ConversationDetailsMediaList';
|
||||||
import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList';
|
import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList';
|
||||||
import {
|
import { EditConversationAttributesModal } from './EditConversationAttributesModal';
|
||||||
EditConversationAttributesModal,
|
import { RequestState } from './util';
|
||||||
RequestState as EditGroupAttributesRequestState,
|
|
||||||
} from './EditConversationAttributesModal';
|
enum ModalState {
|
||||||
|
NothingOpen,
|
||||||
|
EditingGroupAttributes,
|
||||||
|
AddingGroupMembers,
|
||||||
|
}
|
||||||
|
|
||||||
export type StateProps = {
|
export type StateProps = {
|
||||||
|
addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>;
|
||||||
canEditGroupInfo: boolean;
|
canEditGroupInfo: boolean;
|
||||||
|
candidateContactsToAdd: Array<ConversationType>;
|
||||||
conversation?: ConversationType;
|
conversation?: ConversationType;
|
||||||
hasGroupLink: boolean;
|
hasGroupLink: boolean;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
@ -53,7 +62,9 @@ export type StateProps = {
|
||||||
export type Props = StateProps;
|
export type Props = StateProps;
|
||||||
|
|
||||||
export const ConversationDetails: React.ComponentType<Props> = ({
|
export const ConversationDetails: React.ComponentType<Props> = ({
|
||||||
|
addMembers,
|
||||||
canEditGroupInfo,
|
canEditGroupInfo,
|
||||||
|
candidateContactsToAdd,
|
||||||
conversation,
|
conversation,
|
||||||
hasGroupLink,
|
hasGroupLink,
|
||||||
i18n,
|
i18n,
|
||||||
|
@ -70,15 +81,17 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
||||||
onBlockAndDelete,
|
onBlockAndDelete,
|
||||||
onDelete,
|
onDelete,
|
||||||
}) => {
|
}) => {
|
||||||
const [isEditingGroupAttributes, setIsEditingGroupAttributes] = useState(
|
const [modalState, setModalState] = useState<ModalState>(
|
||||||
false
|
ModalState.NothingOpen
|
||||||
);
|
);
|
||||||
const [
|
const [
|
||||||
editGroupAttributesRequestState,
|
editGroupAttributesRequestState,
|
||||||
setEditGroupAttributesRequestState,
|
setEditGroupAttributesRequestState,
|
||||||
] = useState<EditGroupAttributesRequestState>(
|
] = useState<RequestState>(RequestState.Inactive);
|
||||||
EditGroupAttributesRequestState.Inactive
|
const [
|
||||||
);
|
addGroupMembersRequestState,
|
||||||
|
setAddGroupMembersRequestState,
|
||||||
|
] = useState<RequestState>(RequestState.Inactive);
|
||||||
|
|
||||||
const updateExpireTimer = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
const updateExpireTimer = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
setDisappearingMessages(parseInt(event.target.value, 10));
|
setDisappearingMessages(parseInt(event.target.value, 10));
|
||||||
|
@ -94,6 +107,88 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
||||||
const invitesCount =
|
const invitesCount =
|
||||||
pendingMemberships.length + pendingApprovalMemberships.length;
|
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 (
|
return (
|
||||||
<div className="conversation-details-panel">
|
<div className="conversation-details-panel">
|
||||||
<ConversationDetailsHeader
|
<ConversationDetailsHeader
|
||||||
|
@ -101,7 +196,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
startEditing={() => {
|
startEditing={() => {
|
||||||
setIsEditingGroupAttributes(true);
|
setModalState(ModalState.EditingGroupAttributes);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -141,9 +236,13 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ConversationDetailsMembershipList
|
<ConversationDetailsMembershipList
|
||||||
|
canAddNewMembers={canEditGroupInfo}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
showContactModal={showContactModal}
|
|
||||||
memberships={conversation.memberships || []}
|
memberships={conversation.memberships || []}
|
||||||
|
showContactModal={showContactModal}
|
||||||
|
startAddingNewMembers={() => {
|
||||||
|
setModalState(ModalState.AddingGroupMembers);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PanelSection>
|
<PanelSection>
|
||||||
|
@ -200,42 +299,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
||||||
onBlockAndDelete={onBlockAndDelete}
|
onBlockAndDelete={onBlockAndDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isEditingGroupAttributes && (
|
{modalNode}
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { isBoolean } from 'lodash';
|
||||||
|
|
||||||
import { storiesOf } from '@storybook/react';
|
import { storiesOf } from '@storybook/react';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
|
@ -42,9 +43,13 @@ const createMemberships = (
|
||||||
};
|
};
|
||||||
|
|
||||||
const createProps = (overrideProps: Partial<Props>): Props => ({
|
const createProps = (overrideProps: Partial<Props>): Props => ({
|
||||||
|
canAddNewMembers: isBoolean(overrideProps.canAddNewMembers)
|
||||||
|
? overrideProps.canAddNewMembers
|
||||||
|
: false,
|
||||||
i18n,
|
i18n,
|
||||||
memberships: overrideProps.memberships || [],
|
memberships: overrideProps.memberships || [],
|
||||||
showContactModal: action('showContactModal'),
|
showContactModal: action('showContactModal'),
|
||||||
|
startAddingNewMembers: action('startAddingNewMembers'),
|
||||||
});
|
});
|
||||||
|
|
||||||
story.add('Few', () => {
|
story.add('Few', () => {
|
||||||
|
@ -92,3 +97,11 @@ story.add('None', () => {
|
||||||
|
|
||||||
return <ConversationDetailsMembershipList {...props} />;
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
@ -19,8 +19,10 @@ export type GroupV2Membership = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
|
canAddNewMembers: boolean;
|
||||||
memberships: Array<GroupV2Membership>;
|
memberships: Array<GroupV2Membership>;
|
||||||
showContactModal: (conversationId: string) => void;
|
showContactModal: (conversationId: string) => void;
|
||||||
|
startAddingNewMembers: () => void;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -66,8 +68,10 @@ function sortMemberships(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
|
export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
|
||||||
|
canAddNewMembers,
|
||||||
memberships,
|
memberships,
|
||||||
showContactModal,
|
showContactModal,
|
||||||
|
startAddingNewMembers,
|
||||||
i18n,
|
i18n,
|
||||||
}) => {
|
}) => {
|
||||||
const [showAllMembers, setShowAllMembers] = React.useState<boolean>(false);
|
const [showAllMembers, setShowAllMembers] = React.useState<boolean>(false);
|
||||||
|
@ -85,6 +89,15 @@ export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
|
||||||
sortedMemberships.length.toString(),
|
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 }) => (
|
{sortedMemberships.slice(0, membersToShow).map(({ isAdmin, member }) => (
|
||||||
<PanelRow
|
<PanelRow
|
||||||
key={member.id}
|
key={member.id}
|
||||||
|
|
|
@ -8,10 +8,8 @@ import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
import { setup as setupI18n } from '../../../../js/modules/i18n';
|
||||||
import enMessages from '../../../../_locales/en/messages.json';
|
import enMessages from '../../../../_locales/en/messages.json';
|
||||||
import {
|
import { EditConversationAttributesModal } from './EditConversationAttributesModal';
|
||||||
EditConversationAttributesModal,
|
import { RequestState } from './util';
|
||||||
RequestState,
|
|
||||||
} from './EditConversationAttributesModal';
|
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { Spinner } from '../../Spinner';
|
||||||
import { GroupTitleInput } from '../../GroupTitleInput';
|
import { GroupTitleInput } from '../../GroupTitleInput';
|
||||||
import * as log from '../../../logging/log';
|
import * as log from '../../../logging/log';
|
||||||
import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer';
|
import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer';
|
||||||
|
import { RequestState } from './util';
|
||||||
|
|
||||||
const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
|
const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
|
||||||
|
|
||||||
|
@ -35,12 +36,6 @@ type PropsType = {
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum RequestState {
|
|
||||||
Inactive,
|
|
||||||
InactiveWithError,
|
|
||||||
Active,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
||||||
avatarPath: externalAvatarPath,
|
avatarPath: externalAvatarPath,
|
||||||
i18n,
|
i18n,
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
export enum RequestState {
|
||||||
|
Inactive,
|
||||||
|
InactiveWithError,
|
||||||
|
Active,
|
||||||
|
}
|
||||||
|
|
||||||
export const bemGenerator = (block: string) => (
|
export const bemGenerator = (block: string) => (
|
||||||
element: string,
|
element: string,
|
||||||
modifier?: string | Record<string, boolean>
|
modifier?: string | Record<string, boolean>
|
||||||
|
|
|
@ -98,6 +98,11 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
|
||||||
className={CHECKBOX_CLASS_NAME}
|
className={CHECKBOX_CLASS_NAME}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={onClick}
|
onChange={onClick}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (onClick && !disabled && event.key === 'Enter') {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 { BaseConversationListItem } from './BaseConversationListItem';
|
||||||
import { ColorType } from '../../types/Colors';
|
import { ColorType } from '../../types/Colors';
|
||||||
|
@ -11,7 +11,8 @@ import { About } from '../conversation/About';
|
||||||
|
|
||||||
export enum ContactCheckboxDisabledReason {
|
export enum ContactCheckboxDisabledReason {
|
||||||
// We start the enum at 1 because the default starting value of 0 is falsy.
|
// We start the enum at 1 because the default starting value of 0 is falsy.
|
||||||
MaximumContactsSelected = 1,
|
AlreadyAdded = 1,
|
||||||
|
MaximumContactsSelected,
|
||||||
NotCapable,
|
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 = () => {
|
const onClickItem = () => {
|
||||||
onClick(id, disabledReason);
|
onClick(id, disabledReason);
|
||||||
|
|
|
@ -9,7 +9,10 @@ import { ConversationType } from '../../state/ducks/conversations';
|
||||||
import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox';
|
import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox';
|
||||||
import { ContactPills } from '../ContactPills';
|
import { ContactPills } from '../ContactPills';
|
||||||
import { ContactPill } from '../ContactPill';
|
import { ContactPill } from '../ContactPill';
|
||||||
import { Alert } from '../Alert';
|
import {
|
||||||
|
AddGroupMemberErrorDialog,
|
||||||
|
AddGroupMemberErrorDialogMode,
|
||||||
|
} from '../AddGroupMemberErrorDialog';
|
||||||
import { Button } from '../Button';
|
import { Button } from '../Button';
|
||||||
import { LocalizerType } from '../../types/Util';
|
import { LocalizerType } from '../../types/Util';
|
||||||
import {
|
import {
|
||||||
|
@ -111,35 +114,34 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<
|
||||||
) => unknown;
|
) => unknown;
|
||||||
removeSelectedContact: (conversationId: string) => unknown;
|
removeSelectedContact: (conversationId: string) => unknown;
|
||||||
}>): ReactChild {
|
}>): ReactChild {
|
||||||
let modalDetails:
|
let modalNode: undefined | ReactChild;
|
||||||
| undefined
|
|
||||||
| { title: string; body: string; onClose: () => void };
|
|
||||||
if (this.isShowingMaximumGroupSizeModal) {
|
if (this.isShowingMaximumGroupSizeModal) {
|
||||||
modalDetails = {
|
modalNode = (
|
||||||
title: i18n('chooseGroupMembers__maximum-group-size__title'),
|
<AddGroupMemberErrorDialog
|
||||||
body: i18n('chooseGroupMembers__maximum-group-size__body', [
|
i18n={i18n}
|
||||||
this.getMaximumNumberOfContacts().toString(),
|
maximumNumberOfContacts={this.getMaximumNumberOfContacts()}
|
||||||
]),
|
mode={AddGroupMemberErrorDialogMode.MaximumGroupSize}
|
||||||
onClose: closeMaximumGroupSizeModal,
|
onClose={closeMaximumGroupSizeModal}
|
||||||
};
|
/>
|
||||||
|
);
|
||||||
} else if (this.isShowingRecommendedGroupSizeModal) {
|
} else if (this.isShowingRecommendedGroupSizeModal) {
|
||||||
modalDetails = {
|
modalNode = (
|
||||||
title: i18n(
|
<AddGroupMemberErrorDialog
|
||||||
'chooseGroupMembers__maximum-recommended-group-size__title'
|
i18n={i18n}
|
||||||
),
|
recommendedMaximumNumberOfContacts={this.getRecommendedMaximumNumberOfContacts()}
|
||||||
body: i18n('chooseGroupMembers__maximum-recommended-group-size__body', [
|
mode={AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize}
|
||||||
this.getRecommendedMaximumNumberOfContacts().toString(),
|
onClose={closeRecommendedGroupSizeModal}
|
||||||
]),
|
/>
|
||||||
onClose: closeRecommendedGroupSizeModal,
|
);
|
||||||
};
|
|
||||||
} else if (this.cantAddContactForModal) {
|
} else if (this.cantAddContactForModal) {
|
||||||
modalDetails = {
|
modalNode = (
|
||||||
title: i18n('chooseGroupMembers__cant-add-member__title'),
|
<AddGroupMemberErrorDialog
|
||||||
body: i18n('chooseGroupMembers__cant-add-member__body', [
|
i18n={i18n}
|
||||||
this.cantAddContactForModal.title,
|
contact={this.cantAddContactForModal}
|
||||||
]),
|
mode={AddGroupMemberErrorDialogMode.CantAddContact}
|
||||||
onClose: closeCantAddContactToGroupModal,
|
onClose={closeCantAddContactToGroupModal}
|
||||||
};
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -149,7 +151,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<
|
||||||
type="text"
|
type="text"
|
||||||
ref={focusRef}
|
ref={focusRef}
|
||||||
className="module-left-pane__compose-search-form__input"
|
className="module-left-pane__compose-search-form__input"
|
||||||
placeholder={i18n('newConversationContactSearchPlaceholder')}
|
placeholder={i18n('contactSearchPlaceholder')}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
value={this.searchTerm}
|
value={this.searchTerm}
|
||||||
onChange={onChangeComposeSearchTerm}
|
onChange={onChangeComposeSearchTerm}
|
||||||
|
@ -178,18 +180,11 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<
|
||||||
|
|
||||||
{this.getRowCount() ? null : (
|
{this.getRowCount() ? null : (
|
||||||
<div className="module-left-pane__compose-no-contacts">
|
<div className="module-left-pane__compose-no-contacts">
|
||||||
{i18n('newConversationNoContacts')}
|
{i18n('noContactsFound')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{modalDetails && (
|
{modalNode}
|
||||||
<Alert
|
|
||||||
body={modalDetails.body}
|
|
||||||
i18n={i18n}
|
|
||||||
onClose={modalDetails.onClose}
|
|
||||||
title={modalDetails.title}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -90,7 +90,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
|
||||||
type="text"
|
type="text"
|
||||||
ref={focusRef}
|
ref={focusRef}
|
||||||
className="module-left-pane__compose-search-form__input"
|
className="module-left-pane__compose-search-form__input"
|
||||||
placeholder={i18n('newConversationContactSearchPlaceholder')}
|
placeholder={i18n('contactSearchPlaceholder')}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
value={this.searchTerm}
|
value={this.searchTerm}
|
||||||
onChange={onChangeComposeSearchTerm}
|
onChange={onChangeComposeSearchTerm}
|
||||||
|
@ -99,7 +99,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
|
||||||
|
|
||||||
{this.getRowCount() ? null : (
|
{this.getRowCount() ? null : (
|
||||||
<div className="module-left-pane__compose-no-contacts">
|
<div className="module-left-pane__compose-no-contacts">
|
||||||
{i18n('newConversationNoContacts')}
|
{i18n('noContactsFound')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
142
ts/groups.ts
142
ts/groups.ts
|
@ -550,6 +550,148 @@ function buildGroupProto(
|
||||||
return proto;
|
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(
|
export async function buildUpdateAttributesChange(
|
||||||
conversation: Pick<
|
conversation: Pick<
|
||||||
ConversationAttributesType,
|
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(
|
async updateGroupAttributesV2(
|
||||||
attributes: Readonly<{
|
attributes: Readonly<{
|
||||||
avatar?: undefined | ArrayBuffer;
|
avatar?: undefined | ArrayBuffer;
|
||||||
|
|
|
@ -35,6 +35,7 @@ import {
|
||||||
getGroupSizeRecommendedLimit,
|
getGroupSizeRecommendedLimit,
|
||||||
getGroupSizeHardLimit,
|
getGroupSizeHardLimit,
|
||||||
} from '../../groups/limits';
|
} from '../../groups/limits';
|
||||||
|
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
@ -2273,50 +2274,23 @@ export function reducer(
|
||||||
return state;
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
composer: {
|
composer: {
|
||||||
...composer,
|
...composer,
|
||||||
maximumGroupSizeModalState,
|
...toggleSelectedContactForGroupAddition(
|
||||||
recommendedGroupSizeModalState,
|
action.payload.conversationId,
|
||||||
selectedConversationIds,
|
{
|
||||||
|
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 memoizee from 'memoizee';
|
||||||
import { fromPairs, isNumber, isString } from 'lodash';
|
import { fromPairs, isNumber, isString } from 'lodash';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import Fuse, { FuseOptions } from 'fuse.js';
|
|
||||||
|
|
||||||
import { StateType } from '../reducer';
|
import { StateType } from '../reducer';
|
||||||
import {
|
import {
|
||||||
|
@ -29,6 +28,7 @@ import { PropsDataType as TimelinePropsType } from '../../components/conversatio
|
||||||
import { TimelineItemType } from '../../components/conversation/TimelineItem';
|
import { TimelineItemType } from '../../components/conversation/TimelineItem';
|
||||||
import { assert } from '../../util/assert';
|
import { assert } from '../../util/assert';
|
||||||
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
|
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
|
||||||
|
import { filterAndSortContacts } from '../../util/filterAndSortContacts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getInteractionMode,
|
getInteractionMode,
|
||||||
|
@ -342,14 +342,14 @@ export const getComposerContactSearchTerm = createSelector(
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This returns contacts for the composer, which isn't just your primary's system
|
* This returns contacts for the composer and group members, which isn't just your primary
|
||||||
* contacts. It may include false positives, which is better than missing contacts.
|
* 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
|
* 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
|
* 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.
|
* if no other conversations change. This should be a rare false positive.
|
||||||
*/
|
*/
|
||||||
const getContacts = createSelector(
|
export const getContacts = createSelector(
|
||||||
getConversationLookup,
|
getConversationLookup,
|
||||||
(conversationLookup: ConversationLookupType): Array<ConversationType> =>
|
(conversationLookup: ConversationLookupType): Array<ConversationType> =>
|
||||||
Object.values(conversationLookup).filter(
|
Object.values(conversationLookup).filter(
|
||||||
|
@ -371,13 +371,6 @@ const getNoteToSelfTitle = createSelector(getIntl, (i18n: LocalizerType) =>
|
||||||
i18n('noteToSelf').toLowerCase()
|
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(
|
export const getComposeContacts = createSelector(
|
||||||
getNormalizedComposerContactSearchTerm,
|
getNormalizedComposerContactSearchTerm,
|
||||||
getContacts,
|
getContacts,
|
||||||
|
@ -389,55 +382,21 @@ export const getComposeContacts = createSelector(
|
||||||
noteToSelf: ConversationType,
|
noteToSelf: ConversationType,
|
||||||
noteToSelfTitle: string
|
noteToSelfTitle: string
|
||||||
): Array<ConversationType> => {
|
): Array<ConversationType> => {
|
||||||
let result: Array<ConversationType>;
|
const result: Array<ConversationType> = filterAndSortContacts(
|
||||||
|
contacts,
|
||||||
if (searchTerm.length) {
|
searchTerm
|
||||||
const fuse = new Fuse<ConversationType>(
|
);
|
||||||
contacts,
|
if (!searchTerm || noteToSelfTitle.includes(searchTerm)) {
|
||||||
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));
|
|
||||||
result.push(noteToSelf);
|
result.push(noteToSelf);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/*
|
export const getCandidateContactsForNewGroup = createSelector(
|
||||||
* This returns contacts for the composer when you're picking new group members. It casts
|
getContacts,
|
||||||
* 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(
|
|
||||||
getNormalizedComposerContactSearchTerm,
|
getNormalizedComposerContactSearchTerm,
|
||||||
getGroupContacts,
|
filterAndSortContacts
|
||||||
(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));
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getCantAddContactForModal = createSelector(
|
export const getCantAddContactForModal = createSelector(
|
||||||
|
|
|
@ -8,11 +8,15 @@ import {
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
StateProps,
|
StateProps,
|
||||||
} from '../../components/conversation/conversation-details/ConversationDetails';
|
} from '../../components/conversation/conversation-details/ConversationDetails';
|
||||||
import { getConversationSelector } from '../selectors/conversations';
|
import {
|
||||||
|
getContacts,
|
||||||
|
getConversationSelector,
|
||||||
|
} from '../selectors/conversations';
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
import { MediaItemType } from '../../components/LightboxGallery';
|
import { MediaItemType } from '../../components/LightboxGallery';
|
||||||
|
|
||||||
export type SmartConversationDetailsProps = {
|
export type SmartConversationDetailsProps = {
|
||||||
|
addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
hasGroupLink: boolean;
|
hasGroupLink: boolean;
|
||||||
loadRecentMediaItems: (limit: number) => void;
|
loadRecentMediaItems: (limit: number) => void;
|
||||||
|
@ -46,10 +50,12 @@ const mapStateToProps = (
|
||||||
? conversation.canEditGroupInfo
|
? conversation.canEditGroupInfo
|
||||||
: false;
|
: false;
|
||||||
const isAdmin = Boolean(conversation?.areWeAdmin);
|
const isAdmin = Boolean(conversation?.areWeAdmin);
|
||||||
|
const candidateContactsToAdd = getContacts(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...props,
|
...props,
|
||||||
canEditGroupInfo,
|
canEditGroupInfo,
|
||||||
|
candidateContactsToAdd,
|
||||||
conversation,
|
conversation,
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { ComposerStep, OneTimeModalState } from '../ducks/conversations';
|
||||||
import { getSearchResults, isSearching } from '../selectors/search';
|
import { getSearchResults, isSearching } from '../selectors/search';
|
||||||
import { getIntl, getRegionCode } from '../selectors/user';
|
import { getIntl, getRegionCode } from '../selectors/user';
|
||||||
import {
|
import {
|
||||||
getCandidateGroupContacts,
|
getCandidateContactsForNewGroup,
|
||||||
getCantAddContactForModal,
|
getCantAddContactForModal,
|
||||||
getComposeContacts,
|
getComposeContacts,
|
||||||
getComposeGroupAvatar,
|
getComposeGroupAvatar,
|
||||||
|
@ -102,7 +102,7 @@ const getModeSpecificProps = (
|
||||||
case ComposerStep.ChooseGroupMembers:
|
case ComposerStep.ChooseGroupMembers:
|
||||||
return {
|
return {
|
||||||
mode: LeftPaneMode.ChooseGroupMembers,
|
mode: LeftPaneMode.ChooseGroupMembers,
|
||||||
candidateContacts: getCandidateGroupContacts(state),
|
candidateContacts: getCandidateContactsForNewGroup(state),
|
||||||
cantAddContactForModal: getCantAddContactForModal(state),
|
cantAddContactForModal: getCantAddContactForModal(state),
|
||||||
isShowingRecommendedGroupSizeModal:
|
isShowingRecommendedGroupSizeModal:
|
||||||
getRecommendedGroupSizeModalState(state) ===
|
getRecommendedGroupSizeModalState(state) ===
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { v4 as generateUuid } from 'uuid';
|
import { v4 as generateUuid } from 'uuid';
|
||||||
|
import { sample } from 'lodash';
|
||||||
import { ConversationType } from '../../state/ducks/conversations';
|
import { ConversationType } from '../../state/ducks/conversations';
|
||||||
|
|
||||||
const FIRST_NAMES = [
|
const FIRST_NAMES = [
|
||||||
|
@ -310,21 +311,23 @@ const LAST_NAMES = [
|
||||||
'Jimenez',
|
'Jimenez',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getRandomTitle(): string {
|
const getFirstName = (): string => sample(FIRST_NAMES) || 'Test';
|
||||||
const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
|
const getLastName = (): string => sample(LAST_NAMES) || 'Test';
|
||||||
const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
|
|
||||||
return `${firstName} ${lastName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultConversation(
|
export function getDefaultConversation(
|
||||||
overrideProps: Partial<ConversationType>
|
overrideProps: Partial<ConversationType> = {}
|
||||||
): ConversationType {
|
): ConversationType {
|
||||||
|
const firstName = getFirstName();
|
||||||
|
const lastName = getLastName();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateUuid(),
|
id: generateUuid(),
|
||||||
|
isGroupV2Capable: true,
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
markedUnread: Boolean(overrideProps.markedUnread),
|
markedUnread: Boolean(overrideProps.markedUnread),
|
||||||
e164: '+1300555000',
|
e164: '+1300555000',
|
||||||
title: getRandomTitle(),
|
firstName,
|
||||||
|
title: `${firstName} ${lastName}`,
|
||||||
type: 'direct' as const,
|
type: 'direct' as const,
|
||||||
uuid: generateUuid(),
|
uuid: generateUuid(),
|
||||||
...overrideProps,
|
...overrideProps,
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {
|
||||||
import {
|
import {
|
||||||
_getConversationComparator,
|
_getConversationComparator,
|
||||||
_getLeftPaneLists,
|
_getLeftPaneLists,
|
||||||
getCandidateGroupContacts,
|
getCandidateContactsForNewGroup,
|
||||||
getCantAddContactForModal,
|
getCantAddContactForModal,
|
||||||
getComposeContacts,
|
getComposeContacts,
|
||||||
getComposeGroupAvatar,
|
getComposeGroupAvatar,
|
||||||
|
@ -555,7 +555,7 @@ describe('both/state/selectors/conversations', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#getCandidateGroupContacts', () => {
|
describe('#getCandidateContactsForNewGroup', () => {
|
||||||
const getRootState = (contactSearchTerm = ''): StateType => {
|
const getRootState = (contactSearchTerm = ''): StateType => {
|
||||||
const rootState = getEmptyRootState();
|
const rootState = getEmptyRootState();
|
||||||
return {
|
return {
|
||||||
|
@ -574,7 +574,7 @@ describe('both/state/selectors/conversations', () => {
|
||||||
},
|
},
|
||||||
'convo-2': {
|
'convo-2': {
|
||||||
...getDefaultConversation('convo-2'),
|
...getDefaultConversation('convo-2'),
|
||||||
title: 'B. Sorted Second',
|
title: 'Should be dropped (has no name)',
|
||||||
},
|
},
|
||||||
'convo-3': {
|
'convo-3': {
|
||||||
...getDefaultConversation('convo-3'),
|
...getDefaultConversation('convo-3'),
|
||||||
|
@ -584,19 +584,17 @@ describe('both/state/selectors/conversations', () => {
|
||||||
'convo-4': {
|
'convo-4': {
|
||||||
...getDefaultConversation('convo-4'),
|
...getDefaultConversation('convo-4'),
|
||||||
isBlocked: true,
|
isBlocked: true,
|
||||||
|
name: 'My Name',
|
||||||
title: 'Should Be Dropped (blocked)',
|
title: 'Should Be Dropped (blocked)',
|
||||||
},
|
},
|
||||||
'convo-5': {
|
'convo-5': {
|
||||||
...getDefaultConversation('convo-5'),
|
...getDefaultConversation('convo-5'),
|
||||||
discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(),
|
discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(),
|
||||||
|
name: 'My Name',
|
||||||
title: 'Should Be Dropped (unregistered)',
|
title: 'Should Be Dropped (unregistered)',
|
||||||
},
|
},
|
||||||
'convo-6': {
|
'convo-6': {
|
||||||
...getDefaultConversation('convo-6'),
|
...getDefaultConversation('convo-6'),
|
||||||
title: 'D. Sorted Last',
|
|
||||||
},
|
|
||||||
'convo-7': {
|
|
||||||
...getDefaultConversation('convo-7'),
|
|
||||||
discoveredUnregisteredAt: Date.now(),
|
discoveredUnregisteredAt: Date.now(),
|
||||||
name: 'In System Contacts (and only recently unregistered)',
|
name: 'In System Contacts (and only recently unregistered)',
|
||||||
title: 'C. Sorted Third',
|
title: 'C. Sorted Third',
|
||||||
|
@ -623,18 +621,18 @@ describe('both/state/selectors/conversations', () => {
|
||||||
|
|
||||||
it('returns sorted contacts when there is no search term', () => {
|
it('returns sorted contacts when there is no search term', () => {
|
||||||
const state = getRootState();
|
const state = getRootState();
|
||||||
const result = getCandidateGroupContacts(state);
|
const result = getCandidateContactsForNewGroup(state);
|
||||||
|
|
||||||
const ids = result.map(contact => contact.id);
|
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', () => {
|
it('can search for contacts', () => {
|
||||||
const state = getRootState('system contacts');
|
const state = getRootState('system contacts');
|
||||||
const result = getCandidateGroupContacts(state);
|
const result = getCandidateContactsForNewGroup(state);
|
||||||
|
|
||||||
const ids = result.map(contact => contact.id);
|
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 action = getAction(uuid(), state);
|
||||||
const result = reducer(state, action);
|
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', () => {
|
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",
|
"updated": "2019-07-31T00:19:18.696Z",
|
||||||
"reasonDetail": "Timeline needs to interact with its child List directly"
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js",
|
"path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js",
|
||||||
"line": " const startingTitleRef = react_1.useRef(externalTitle);",
|
"line": " const startingTitleRef = react_1.useRef(externalTitle);",
|
||||||
"lineNumber": 42,
|
"lineNumber": 37,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-03-05T22:52:40.572Z",
|
"updated": "2021-03-05T22:52:40.572Z",
|
||||||
"reasonDetail": "Doesn't interact with the DOM."
|
"reasonDetail": "Doesn't interact with the DOM."
|
||||||
|
@ -15090,7 +15099,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js",
|
"path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js",
|
||||||
"line": " const startingAvatarPathRef = react_1.useRef(externalAvatarPath);",
|
"line": " const startingAvatarPathRef = react_1.useRef(externalAvatarPath);",
|
||||||
"lineNumber": 43,
|
"lineNumber": 38,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-03-05T22:52:40.572Z",
|
"updated": "2021-03-05T22:52:40.572Z",
|
||||||
"reasonDetail": "Doesn't interact with the DOM."
|
"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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
export function makeLookup<T>(
|
export function makeLookup<T>(
|
||||||
items: Array<T>,
|
items: ReadonlyArray<T>,
|
||||||
key: keyof T
|
key: keyof T
|
||||||
): Record<string, T> {
|
): Record<string, T> {
|
||||||
return (items || []).reduce((lookup, item) => {
|
return (items || []).reduce((lookup, item) => {
|
||||||
|
|
|
@ -2887,6 +2887,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
ACCESS_ENUM.UNSATISFIABLE;
|
ACCESS_ENUM.UNSATISFIABLE;
|
||||||
|
|
||||||
const props = {
|
const props = {
|
||||||
|
addMembers: conversation.addMembersV2.bind(conversation),
|
||||||
conversationId: conversation.get('id'),
|
conversationId: conversation.get('id'),
|
||||||
hasGroupLink,
|
hasGroupLink,
|
||||||
loadRecentMediaItems: this.loadRecentMediaItems.bind(this),
|
loadRecentMediaItems: this.loadRecentMediaItems.bind(this),
|
||||||
|
|
Loading…
Reference in a new issue