Support for creating New Groups

This commit is contained in:
Evan Hahn 2021-03-03 14:09:58 -06:00 committed by Josh Perez
parent 1934120e46
commit 5de4babc0d
56 changed files with 6222 additions and 526 deletions

View File

@ -1905,6 +1905,88 @@
"message": "No contacts found",
"description": "Label shown when there are no contacts to compose to"
},
"chooseGroupMembers__title": {
"message": "Choose members",
"description": "The title for the 'choose group members' left pane screen"
},
"chooseGroupMembers__back-button": {
"message": "Back",
"description": "Used as alt-text of the back button on the 'choose group members' left pane screen"
},
"chooseGroupMembers__skip": {
"message": "Skip",
"description": "The 'skip' button text in the 'choose group members' left pane screen"
},
"chooseGroupMembers__next": {
"message": "Next",
"description": "The 'next' button text in the 'choose group members' left pane screen"
},
"chooseGroupMembers__maximum-group-size__title": {
"message": "Maximum group size reached",
"description": "Shown in the alert when you add the maximum number of group members"
},
"chooseGroupMembers__maximum-group-size__body": {
"message": "Signal groups can have a maximum of $max$ members.",
"description": "Shown in the alert when you add the maximum number of group members",
"placeholders": {
"max": {
"content": "$1",
"example": "1000"
}
}
},
"chooseGroupMembers__maximum-recommended-group-size__title": {
"message": "Recommended member limit reached",
"description": "Shown in the alert when you add the maximum recommended number of group members"
},
"chooseGroupMembers__maximum-recommended-group-size__body": {
"message": "Signal groups perform best with $max$ members or less. Adding more members will cause delays sending and receiving messages.",
"description": "Shown in the alert when you add the maximum recommended number of group members",
"placeholders": {
"max": {
"content": "$1",
"example": "150"
}
}
},
"chooseGroupMembers__cant-add-member__title": {
"message": "Cant add member",
"description": "Shown in the alert when you try to add someone who can't be added to a group"
},
"chooseGroupMembers__cant-add-member__body": {
"message": "“$name$” cant be added to the group because theyre using an old version of Signal. You can add them to the group after theyve updated Signal.",
"description": "Shown in the alert when you try to add someone who can't be added to a group",
"placeholders": {
"max": {
"content": "$1",
"example": "Jane Doe"
}
}
},
"setGroupMetadata__title": {
"message": "Name this group",
"description": "The title for the 'set group metadata' left pane screen"
},
"setGroupMetadata__back-button": {
"message": "Back to member selection",
"description": "Used as alt-text of the back button on the 'set group metadata' left pane screen"
},
"setGroupMetadata__group-name-placeholder": {
"message": "Group name (required)",
"description": "The placeholder for the group name placeholder"
},
"setGroupMetadata__create-group": {
"message": "Create",
"description": "The 'create group' button text in the 'set group metadata' left pane screen"
},
"setGroupMetadata__members-header": {
"message": "Members",
"description": "The header for the members list in the 'set group metadata' left pane screen"
},
"setGroupMetadata__error-message": {
"message": "This group couldnt be created. Check your connection and try again.",
"description": "Shown in the modal when we can't create a group"
},
"notSupportedSMS": {
"message": "SMS/MMS messages are not supported.",
"description": "Label underneath number informing user that SMS is not supported on desktop"
@ -4876,5 +4958,81 @@
"PendingInvites--info": {
"message": "Details about people invited to this group arent shown until they join. Invitees will only see messages after they join the group.",
"description": "Information shown below the invite list"
},
"AvatarInput--no-photo-label--group": {
"message": "Add a group photo",
"description": "The label for the avatar uploader when no group photo is selected"
},
"AvatarInput--change-photo-label": {
"message": "Change photo",
"description": "The label for the avatar uploader when a photo is selected"
},
"AvatarInput--upload-photo-choice": {
"message": "Upload photo",
"description": "The button text when you click on an uploaded avatar and want to upload a new one"
},
"AvatarInput--remove-photo-choice": {
"message": "Remove photo",
"description": "The button text when you click on an uploaded avatar and want to remove it"
},
"ContactPill--remove": {
"message": "Remove contact",
"description": "The label for the 'remove' button on the contact pill"
},
"ComposeErrorDialog--close": {
"message": "Okay",
"description": "The text on the button when there's an error in the composer"
},
"NewlyCreatedGroupInvitedContactsDialog--title--one": {
"message": "Invitation sent",
"description": "When creating a new group and inviting users, this is shown in the dialog"
},
"NewlyCreatedGroupInvitedContactsDialog--title--many": {
"message": "$count$ invitations sent",
"description": "When creating a new group and inviting users, this is shown in the dialog",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--one": {
"message": "$name$ cant be automatically added to this group by you.",
"description": "When creating a new group and inviting users, this is shown in the dialog",
"placeholders": {
"name": {
"content": "$1",
"example": "Jane Doe"
}
}
},
"NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--many": {
"message": "These users cant be automatically added to this group by you.",
"description": "When creating a new group and inviting users, this is shown in the dialog"
},
"NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph": {
"message": "Theyve been invited to join, and wont see any group messages until they accept.",
"description": "When creating a new group and inviting users, this is shown in the dialog"
},
"NewlyCreatedGroupInvitedContactsDialog--body--learn-more": {
"message": "Learn more",
"description": "When creating a new group and inviting users, this is shown in the dialog"
},
"createNewGroupButton": {
"message": "New group",
"description": "The text of the button to create new groups"
},
"selectContact": {
"message": "Select contact",
"description": "The label for contact checkboxes that are non-selected (clicking them should select the contact)"
},
"deselectContact": {
"message": "De-select contact",
"description": "The label for contact checkboxes that are selected (clicking them should de-select the contact)"
},
"cannotSelectContact": {
"message": "Cannot select contact",
"description": "The label for contact checkboxes that are disabled"
}
}

View File

@ -0,0 +1 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m14.279 3 2 2.5h2.221a3 3 0 0 1 3 3v9a3 3 0 0 1 -3 3h-13a3 3 0 0 1 -3-3v-9a3 3 0 0 1 3-3h2.221l2-2.5zm0-1.5h-4.558a1.5 1.5 0 0 0 -1.171.563l-1.55 1.937h-1.5a4.5 4.5 0 0 0 -4.5 4.5v9a4.5 4.5 0 0 0 4.5 4.5h13a4.5 4.5 0 0 0 4.5-4.5v-9a4.5 4.5 0 0 0 -4.5-4.5h-1.5l-1.55-1.937a1.5 1.5 0 0 0 -1.171-.563zm-2.279 6.5a4.5 4.5 0 1 1 -4.5 4.5 4.505 4.505 0 0 1 4.5-4.5m0-1.5a6 6 0 1 0 6 6 6 6 0 0 0 -6-6z"/></svg>

After

Width:  |  Height:  |  Size: 495 B

View File

@ -3977,6 +3977,7 @@ button.module-conversation-details__action-button {
}
.module-avatar--28 {
min-width: 28px;
height: 28px;
width: 28px;
@ -4024,6 +4025,7 @@ button.module-conversation-details__action-button {
.module-avatar--32 {
height: 32px;
width: 32px;
min-width: 32px;
img {
height: 32px;
@ -4069,6 +4071,7 @@ button.module-conversation-details__action-button {
.module-avatar--52 {
height: 52px;
width: 52px;
min-width: 52px;
img {
height: 52px;
@ -4095,6 +4098,7 @@ button.module-conversation-details__action-button {
.module-avatar--80 {
height: 80px;
width: 80px;
min-width: 80px;
img {
height: 80px;
@ -4121,6 +4125,7 @@ button.module-conversation-details__action-button {
.module-avatar--96 {
height: 96px;
width: 96px;
min-width: 96px;
img {
height: 96px;
@ -4142,6 +4147,7 @@ button.module-conversation-details__action-button {
.module-avatar--112 {
height: 112px;
width: 112px;
min-width: 112px;
img {
height: 112px;
@ -6854,21 +6860,49 @@ button.module-image__border-overlay:focus {
&--contact-or-conversation {
@include button-reset;
width: 100%;
align-items: center;
cursor: inherit;
display: flex;
flex-direction: row;
padding-right: 16px;
padding-left: 16px;
align-items: center;
padding-right: 16px;
user-select: none;
width: 100%;
&:hover,
&:focus {
@include light-theme {
background-color: $color-gray-05;
&--is-button {
cursor: pointer;
&:disabled {
cursor: inherit;
}
@include dark-theme {
background-color: $color-gray-75;
&:hover:not(:disabled),
&:focus:not(:disabled) {
@include light-theme {
background-color: $color-gray-05;
}
@include dark-theme {
background-color: $color-gray-75;
}
}
}
&--is-checkbox {
cursor: pointer;
&--disabled {
cursor: not-allowed;
}
$disabled-selector: '#{&}--disabled';
&:hover:not(#{$disabled-selector}),
&:focus:not(#{$disabled-selector}) {
@include light-theme {
background-color: $color-gray-05;
}
@include dark-theme {
background-color: $color-gray-75;
}
}
}
@ -6931,12 +6965,14 @@ button.module-image__border-overlay:focus {
&__content {
flex-grow: 1;
margin-left: 12px;
// parent - 52px (for avatar) - 12p (margin to avatar)
max-width: calc(100% - 64px);
display: flex;
flex-direction: column;
align-items: stretch;
overflow: hidden;
&--disabled {
opacity: 0.5;
}
&__header {
display: flex;
@ -7153,6 +7189,68 @@ button.module-image__border-overlay:focus {
}
}
}
&__checkbox {
-webkit-appearance: none;
background: $color-white;
border-radius: 100%;
height: 20px;
margin-left: 16px;
margin-right: 16px;
width: 20px;
min-width: 20px;
pointer-events: none;
@include light-theme {
border: 1px solid $color-gray-15;
}
@include dark-theme {
border: 1px solid $color-gray-80;
}
&:focus {
outline: none;
}
@include keyboard-mode {
&:focus {
border-width: 2px;
border-color: $ultramarine-ui-light;
&:checked {
box-shadow: inset 0 0 0px 1px $color-white;
}
}
}
@include dark-keyboard-mode {
&:focus {
border-width: 2px;
border-color: $ultramarine-ui-dark;
&:checked {
box-shadow: inset 0 0 0px 1px $color-black;
}
}
}
&:disabled {
opacity: 0.5;
}
&:checked {
background: $ultramarine-ui-light;
display: flex;
align-items: center;
justify-content: center;
&::before {
content: '';
display: block;
@include color-svg('../images/icons/v2/check-24.svg', $color-white);
width: 13px;
height: 13px;
}
}
}
}
&--header {
@ -7191,6 +7289,7 @@ button.module-image__border-overlay:focus {
width: $left-pane-width;
height: 100%;
position: relative;
}
.module-left-pane__header {
@ -7215,6 +7314,10 @@ button.module-image__border-overlay:focus {
width: 24px;
height: 24px;
&:disabled {
cursor: not-allowed;
}
@include light-theme {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
@ -7257,6 +7360,11 @@ button.module-image__border-overlay:focus {
}
}
}
&__form {
display: flex;
flex-direction: column;
}
}
.module-left-pane__archive-helper-text {
@ -7325,6 +7433,27 @@ button.module-image__border-overlay:focus {
}
}
.module-left-pane__compose-input {
margin: 16px;
@include font-body-1;
padding: 8px 12px;
border-radius: 6px;
border: 2px solid $color-gray-15;
background: $color-white;
color: $color-black;
&:focus {
outline: none;
@include light-theme {
border-color: $ultramarine-ui-light;
}
@include dark-theme {
border-color: $ultramarine-ui-dark;
}
}
}
.module-left-pane__list--measure {
flex-grow: 1;
flex-shrink: 1;
@ -7340,6 +7469,25 @@ button.module-image__border-overlay:focus {
outline: none;
}
.module-left-pane__footer {
bottom: 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
left: 0;
padding: 12px;
position: absolute;
width: 100%;
@include light-theme {
background: linear-gradient(transparent, $color-gray-02);
}
@include dark-theme {
background: linear-gradient(transparent, $color-gray-80);
}
}
// Module: Timeline Loading Row
.module-timeline-loading-row {
@ -10344,143 +10492,6 @@ button.module-image__border-overlay:focus {
padding: 20px;
}
// Module: GV1 Migration Dialog
.module-group-v2-migration-dialog {
@include font-body-1;
border-radius: 8px;
width: 360px;
margin-left: auto;
margin-right: auto;
padding: 20px;
max-height: 100%;
display: flex;
flex-direction: column;
position: relative;
@include light-theme {
background-color: $color-white;
}
@include dark-theme {
background-color: $color-gray-95;
}
}
.module-group-v2-migration-dialog__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;
}
}
}
.module-group-v2-migration-dialog__title {
@include font-title-2;
text-align: center;
margin-bottom: 20px;
flex-grow: 0;
flex-shrink: 0;
}
.module-group-v2-migration-dialog__scrollable {
overflow-x: scroll;
flex-grow: 1;
flex-shrink: 1;
}
.module-group-v2-migration-dialog__item {
display: flex;
flex-direction: row;
align-items: start;
&:not(:last-of-type) {
margin-bottom: 16px;
}
}
.module-group-v2-migration-dialog__item__bullet {
width: 4px;
height: 11px;
flex-grow: 0;
flex-shrink: 0;
margin-top: 5px;
@include light-theme {
background-color: $color-gray-15;
}
@include dark-theme {
background-color: $color-gray-65;
}
}
.module-group-v2-migration-dialog__item__content {
margin-left: 16px;
}
.module-group-v2-migration-dialog__member {
margin-top: 16px;
}
.module-group-v2-migration-dialog__member__name {
margin-left: 6px;
}
.module-group-v2-migration-dialog__buttons {
margin-top: 16px;
text-align: center;
flex-grow: 0;
flex-shrink: 0;
display: flex;
}
.module-group-v2-migration-dialog__buttons--narrow {
margin-left: auto;
margin-right: auto;
width: 152px;
}
.module-group-v2-migration-dialog__button {
@include button-reset;
@include font-body-1-bold;
// Start flex basis at zero so text width doesn't affect layout. We want the buttons
// evenly distributed.
flex: 1 1 0px;
border-radius: 4px;
padding: 8px;
padding-left: 30px;
padding-right: 30px;
@include button-primary;
&:not(:first-of-type) {
margin-left: 16px;
}
}
.module-group-v2-migration-dialog__button--secondary {
@include button-secondary;
}
// Module: GroupV2 Join Dialog
.module-group-v2-join-dialog {

View File

@ -0,0 +1,39 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-Alert {
@include popper-shadow();
border-radius: 8px;
margin: 0 auto;
max-width: 360px;
padding: 16px;
width: 95%;
@include light-theme() {
background: $color-white;
color: $color-gray-90;
}
@include dark-theme() {
background: $color-gray-95;
color: $color-gray-05;
}
&__title {
@include font-body-1-bold;
margin: 0;
padding: 0;
}
&__body {
@include font-body-1;
margin: 0;
padding: 0;
}
&__button-container {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}

View File

@ -0,0 +1,77 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-AvatarInput {
@include button-reset;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
background: none;
&__avatar {
@include button-reset;
margin-top: 4px;
display: flex;
border-radius: 100%;
height: 80px;
width: 80px;
transition: background-color 100ms ease-out;
&--nothing {
align-items: stretch;
background: $color-white;
&::before {
flex-grow: 1;
content: '';
display: block;
@include color-svg(
'../images/icons/v2/camera-outline-24.svg',
$ultramarine-ui-light,
false
);
-webkit-mask-size: 24px 24px;
}
}
&--loading {
align-items: center;
background: $color-black;
}
&--has-image {
background-size: cover;
background-position: center center;
}
}
&__label {
@include button-reset;
@include font-body-1;
padding-bottom: 4px;
padding-top: 4px;
@include light-theme {
color: $ultramarine-ui-light;
}
@include dark-theme {
color: $ultramarine-ui-dark;
}
}
@include keyboard-mode {
&:focus {
.module-AvatarInput__avatar {
box-shadow: inset 0 0 0 2px $ultramarine-ui-light;
}
.module-AvatarInput__label {
@include font-body-1-bold;
}
}
}
}

View File

@ -0,0 +1,72 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-ContactPill {
align-items: center;
border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.)
display: inline-flex;
user-select: none;
overflow: hidden;
@include light-theme {
color: $color-gray-90;
background: $color-gray-05;
}
@include dark-theme {
color: $color-gray-02;
background: $color-gray-75;
}
&__contact-name {
@include font-body-2;
padding: 0 6px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&__remove {
$icon: '../images/icons/v2/x-24.svg';
@include button-reset;
height: 100%;
display: flex;
width: 28px;
justify-content: center;
align-items: center;
padding: 0 6px 0 4px;
&::before {
content: '';
width: 12px;
height: 12px;
display: block;
@include light-theme {
@include color-svg($icon, $color-gray-60);
}
@include dark-theme {
@include color-svg($icon, $color-gray-25);
}
}
@include keyboard-mode {
&:focus {
background: $color-gray-15;
&::before {
@include color-svg($icon, $ultramarine-ui-light);
}
}
}
@include dark-keyboard-mode {
&:focus {
background: $color-gray-65;
&::before {
@include color-svg($icon, $ultramarine-ui-dark);
}
}
}
}
}

View File

@ -0,0 +1,20 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-ContactPills {
display: flex;
flex-wrap: wrap;
margin-bottom: 10px;
max-height: 88px;
overflow-x: hidden;
overflow-y: scroll;
padding-left: 12px;
scroll-behavior: smooth;
.module-ContactPill {
margin: 4px 6px;
max-width: calc(
100% - 15px
); // 6px for the right margin and 9px for the scrollbar
}
}

View File

@ -0,0 +1,121 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-GroupDialog {
@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;
}
&__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;
}
}
}
&__title {
@include font-title-2;
text-align: center;
margin-bottom: 20px;
flex-grow: 0;
flex-shrink: 0;
}
&__body {
overflow-x: scroll;
flex-grow: 1;
flex-shrink: 1;
}
&__paragraph,
&__contacts {
margin: 0 0 16px 0;
padding: 0 16px 0 28px;
position: relative;
&::before {
content: '';
display: block;
height: 11px;
left: 4px;
position: absolute;
top: 4px;
width: 4px;
@include light-theme {
background-color: $color-gray-15;
}
@include dark-theme {
background-color: $color-gray-65;
}
}
}
&__contacts {
list-style-type: none;
&__contact {
margin-top: 16px;
}
&__contact__name {
margin-left: 8px;
}
}
&__button-container {
display: flex;
justify-content: center;
margin-top: 16px;
flex-grow: 0;
flex-shrink: 0;
.module-Button {
flex-grow: 1;
max-width: 152px;
&:not(:first-child) {
margin-left: 16px;
}
}
}
}

View File

@ -27,5 +27,10 @@
@import 'options';
// New style: components
@import './components/Alert.scss';
@import './components/AvatarInput.scss';
@import './components/Button.scss';
@import './components/ContactPill.scss';
@import './components/ContactPills.scss';
@import './components/GroupDialog.scss';
@import './components/ConversationHeader.scss';

View File

@ -4,7 +4,7 @@
import { get, throttle } from 'lodash';
import { WebAPIType } from './textsecure/WebAPI';
type ConfigKeyType =
export type ConfigKeyType =
| 'desktop.cds'
| 'desktop.clientExpiration'
| 'desktop.disableGV1'

32
ts/components/Alert.tsx Normal file
View File

@ -0,0 +1,32 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent } from 'react';
import { LocalizerType } from '../types/Util';
import { Button } from './Button';
import { ModalHost } from './ModalHost';
type PropsType = {
title?: string;
body: string;
i18n: LocalizerType;
onClose: () => void;
};
export const Alert: FunctionComponent<PropsType> = ({
body,
i18n,
onClose,
title,
}) => (
<ModalHost onClose={onClose}>
<div className="module-Alert">
{title && <h1 className="module-Alert__title">{title}</h1>}
<p className="module-Alert__body">{body}</p>
<div className="module-Alert__button-container">
<Button onClick={onClose}>{i18n('Confirmation--confirm')}</Button>
</div>
</div>
</ModalHost>
);

View File

@ -0,0 +1,69 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState, useEffect } from 'react';
import { v4 as uuid } from 'uuid';
import { chunk, noop } from 'lodash';
import { storiesOf } from '@storybook/react';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { AvatarInput } from './AvatarInput';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/AvatarInput', module);
const TEST_IMAGE = new Uint8Array(
chunk(
'89504e470d0a1a0a0000000d4948445200000008000000080103000000fec12cc800000006504c5445ff00ff00ff000c82e9800000001849444154085b633061a8638863a867f8c720c760c12000001a4302f4d81dd9870000000049454e44ae426082',
2
).map(bytePair => parseInt(bytePair.join(''), 16))
).buffer;
const Wrapper = ({ startValue }: { startValue: undefined | ArrayBuffer }) => {
const [value, setValue] = useState<undefined | ArrayBuffer>(startValue);
const [objectUrl, setObjectUrl] = useState<undefined | string>();
useEffect(() => {
if (!value) {
setObjectUrl(undefined);
return noop;
}
const url = URL.createObjectURL(new Blob([value]));
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [value]);
return (
<>
<div
style={{
background: 'rgba(255, 0, 255, 0.1)',
}}
>
<AvatarInput
contextMenuId={uuid()}
i18n={i18n}
value={value}
onChange={setValue}
/>
</div>
<figure>
<figcaption>Processed image (if it exists)</figcaption>
{objectUrl && <img src={objectUrl} alt="" />}
</figure>
</>
);
};
story.add('No start state', () => {
return <Wrapper startValue={undefined} />;
});
story.add('Starting with a value', () => {
return <Wrapper startValue={TEST_IMAGE} />;
});

View File

@ -0,0 +1,213 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
useRef,
useState,
useEffect,
ChangeEventHandler,
MouseEventHandler,
FunctionComponent,
} from 'react';
import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu';
import loadImage, { LoadImageOptions } from 'blueimp-load-image';
import { noop } from 'lodash';
import { LocalizerType } from '../types/Util';
import { Spinner } from './Spinner';
type PropsType = {
// This ID needs to be globally unique across the app.
contextMenuId: string;
disabled?: boolean;
i18n: LocalizerType;
onChange: (value: undefined | ArrayBuffer) => unknown;
value: undefined | ArrayBuffer;
};
enum ImageStatus {
Nothing = 'nothing',
Loading = 'loading',
HasImage = 'has-image',
}
export const AvatarInput: FunctionComponent<PropsType> = ({
contextMenuId,
disabled,
i18n,
onChange,
value,
}) => {
const fileInputRef = useRef<null | HTMLInputElement>(null);
// Comes from a third-party dependency
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const menuTriggerRef = useRef<null | any>(null);
const [objectUrl, setObjectUrl] = useState<undefined | string>();
useEffect(() => {
if (!value) {
setObjectUrl(undefined);
return noop;
}
const url = URL.createObjectURL(new Blob([value]));
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [value]);
const [processingFile, setProcessingFile] = useState<undefined | File>(
undefined
);
useEffect(() => {
if (!processingFile) {
return noop;
}
let shouldCancel = false;
(async () => {
let newValue: ArrayBuffer;
try {
newValue = await processFile(processingFile);
} catch (err) {
// Processing errors should be rare; if they do, we silently fail. In an ideal
// world, we may want to show a toast instead.
return;
}
if (shouldCancel) {
return;
}
setProcessingFile(undefined);
onChange(newValue);
})();
return () => {
shouldCancel = true;
};
}, [processingFile, onChange]);
const buttonLabel = value
? i18n('AvatarInput--change-photo-label')
: i18n('AvatarInput--no-photo-label--group');
const startUpload = () => {
const fileInput = fileInputRef.current;
if (fileInput) {
fileInput.click();
}
};
const clear = () => {
onChange(undefined);
};
const onClick: MouseEventHandler<unknown> = value
? event => {
const menuTrigger = menuTriggerRef.current;
if (!menuTrigger) {
return;
}
menuTrigger.handleContextClick(event);
}
: startUpload;
const onInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const file = event.target.files && event.target.files[0];
if (file) {
setProcessingFile(file);
}
};
let imageStatus: ImageStatus;
if (processingFile || (value && !objectUrl)) {
imageStatus = ImageStatus.Loading;
} else if (objectUrl) {
imageStatus = ImageStatus.HasImage;
} else {
imageStatus = ImageStatus.Nothing;
}
const isLoading = imageStatus === ImageStatus.Loading;
return (
<>
<ContextMenuTrigger id={contextMenuId} ref={menuTriggerRef}>
<button
type="button"
disabled={disabled || isLoading}
className="module-AvatarInput"
onClick={onClick}
>
<div
className={`module-AvatarInput__avatar module-AvatarInput__avatar--${imageStatus}`}
style={
imageStatus === ImageStatus.HasImage
? {
backgroundImage: `url(${objectUrl})`,
}
: undefined
}
>
{isLoading && (
<Spinner size="70px" svgSize="normal" direction="on-avatar" />
)}
</div>
<span className="module-AvatarInput__label">{buttonLabel}</span>
</button>
</ContextMenuTrigger>
<ContextMenu id={contextMenuId}>
<MenuItem onClick={startUpload}>
{i18n('AvatarInput--upload-photo-choice')}
</MenuItem>
<MenuItem onClick={clear}>
{i18n('AvatarInput--remove-photo-choice')}
</MenuItem>
</ContextMenu>
<input
accept=".gif,.jpg,.jpeg,.png,.webp,image/gif,image/jpeg/image/png,image/webp"
hidden
onChange={onInputChange}
ref={fileInputRef}
type="file"
/>
</>
);
};
async function processFile(file: File): Promise<ArrayBuffer> {
const { image } = await loadImage(file, {
canvas: true,
cover: true,
crop: true,
imageSmoothingQuality: 'medium',
maxHeight: 512,
maxWidth: 512,
minHeight: 2,
minWidth: 2,
// `imageSmoothingQuality` is not present in `loadImage`'s types, but it is
// documented and supported. Updating DefinitelyTyped is the long-term solution
// here.
} as LoadImageOptions);
// NOTE: The types for `loadImage` say this can never be a canvas, but it will be if
// `canvas: true`, at least in our case. Again, updating DefinitelyTyped should
// address this.
if (!(image instanceof HTMLCanvasElement)) {
throw new Error('Loaded image was not a canvas');
}
return (await canvasToBlob(image)).arrayBuffer();
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error("Couldn't convert the canvas to a Blob"));
}
}, 'image/webp');
});
}

View File

@ -0,0 +1,74 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent } from 'react';
import { ColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
import { ContactName } from './conversation/ContactName';
import { Avatar, AvatarSize } from './Avatar';
export type PropsType = {
avatarPath?: string;
color?: ColorType;
firstName?: string;
i18n: LocalizerType;
id: string;
isMe?: boolean;
name?: string;
onClickRemove: (id: string) => void;
phoneNumber?: string;
profileName?: string;
title: string;
};
export const ContactPill: FunctionComponent<PropsType> = ({
avatarPath,
color,
firstName,
i18n,
id,
name,
phoneNumber,
profileName,
title,
onClickRemove,
}) => {
const removeLabel = i18n('ContactPill--remove');
return (
<div className="module-ContactPill">
<Avatar
avatarPath={avatarPath}
color={color}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
size={AvatarSize.TWENTY_EIGHT}
/>
<ContactName
firstName={firstName}
i18n={i18n}
module="module-ContactPill__contact-name"
name={name}
phoneNumber={phoneNumber}
preferFirstName
profileName={profileName}
title={title}
/>
<button
aria-label={removeLabel}
className="module-ContactPill__remove"
onClick={() => {
onClickRemove(id);
}}
title={removeLabel}
type="button"
/>
</div>
);
};

View File

@ -0,0 +1,87 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { times } from 'lodash';
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 { ContactPills } from './ContactPills';
import { ContactPill, PropsType as ContactPillPropsType } from './ContactPill';
import { gifUrl } from '../storybook/Fixtures';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Contact Pills', module);
type ContactType = Omit<ContactPillPropsType, 'i18n' | 'onClickRemove'>;
const contacts: Array<ContactType> = times(50, index => ({
color: 'red',
id: `contact-${index}`,
isMe: false,
name: `Contact ${index}`,
phoneNumber: '(202) 555-0001',
profileName: `C${index}`,
title: `Contact ${index}`,
}));
const contactPillProps = (
overrideProps?: ContactType
): ContactPillPropsType => ({
...(overrideProps || {
avatarPath: gifUrl,
color: 'red',
firstName: 'John',
id: 'abc123',
isMe: false,
name: 'John Bon Bon Jovi',
phoneNumber: '(202) 555-0001',
profileName: 'JohnB',
title: 'John Bon Bon Jovi',
}),
i18n,
onClickRemove: action('onClickRemove'),
});
story.add('Empty list', () => <ContactPills />);
story.add('One contact', () => (
<ContactPills>
<ContactPill {...contactPillProps()} />
</ContactPills>
));
story.add('Three contacts', () => (
<ContactPills>
<ContactPill {...contactPillProps(contacts[0])} />
<ContactPill {...contactPillProps(contacts[1])} />
<ContactPill {...contactPillProps(contacts[2])} />
</ContactPills>
));
story.add('Four contacts, one with a long name', () => (
<ContactPills>
<ContactPill {...contactPillProps(contacts[0])} />
<ContactPill
{...contactPillProps({
...contacts[1],
title:
'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso',
})}
/>
<ContactPill {...contactPillProps(contacts[2])} />
<ContactPill {...contactPillProps(contacts[3])} />
</ContactPills>
));
story.add('Fifty contacts', () => (
<ContactPills>
{contacts.map(contact => (
<ContactPill key={contact.id} {...contactPillProps(contact)} />
))}
</ContactPills>
));

View File

@ -0,0 +1,38 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
useRef,
useEffect,
Children,
FunctionComponent,
ReactNode,
} from 'react';
type PropsType = {
children?: ReactNode;
};
export const ContactPills: FunctionComponent<PropsType> = ({ children }) => {
const elRef = useRef<null | HTMLDivElement>(null);
const childCount = Children.count(children);
const previousChildCountRef = useRef<number>(childCount);
const previousChildCount = previousChildCountRef.current;
previousChildCountRef.current = childCount;
useEffect(() => {
const hasAddedNewChild = childCount > previousChildCount;
const el = elRef.current;
if (!hasAddedNewChild || !el) {
return;
}
el.scrollTop = el.scrollHeight;
}, [childCount, previousChildCount]);
return (
<div className="module-ContactPills" ref={elRef}>
{children}
</div>
);
};

View File

@ -14,6 +14,7 @@ import {
PropsData as ConversationListItemPropsType,
MessageStatuses,
} from './conversationList/ConversationListItem';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
@ -39,6 +40,15 @@ const defaultConversations: Array<ConversationListItemPropsType> = [
title: 'Marc Barraca',
type: 'direct',
},
{
id: 'long-name-convo',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title:
'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso',
type: 'direct',
},
];
const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
@ -52,6 +62,7 @@ const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
i18n,
onSelectConversation: action('onSelectConversation'),
onClickArchiveButton: action('onClickArchiveButton'),
onClickContactCheckbox: action('onClickContactCheckbox'),
renderMessageSearchResult: (id: string, style: React.CSSProperties) => (
<MessageSearchResult
conversationId="marc-convo"
@ -65,6 +76,7 @@ const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
to={defaultConversations[1]}
/>
),
showChooseGroupMembers: action('showChooseGroupMembers'),
startNewConversationFromPhoneNumber: action(
'startNewConversationFromPhoneNumber'
),
@ -144,6 +156,56 @@ story.add('Contact: group', () => (
/>
));
story.add('Contact checkboxes', () => (
<ConversationList
{...createProps([
{
type: RowType.ContactCheckbox,
contact: defaultConversations[0],
isChecked: true,
},
{
type: RowType.ContactCheckbox,
contact: defaultConversations[1],
isChecked: false,
},
{
type: RowType.ContactCheckbox,
contact: {
...defaultConversations[2],
about: '😃 Hola',
},
isChecked: true,
},
])}
/>
));
story.add('Contact checkboxes: disabled', () => (
<ConversationList
{...createProps([
{
type: RowType.ContactCheckbox,
contact: defaultConversations[0],
isChecked: false,
disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected,
},
{
type: RowType.ContactCheckbox,
contact: defaultConversations[1],
isChecked: false,
disabledReason: ContactCheckboxDisabledReason.NotCapable,
},
{
type: RowType.ContactCheckbox,
contact: defaultConversations[2],
isChecked: true,
disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected,
},
])}
/>
));
{
const createConversation = (
overrideProps: Partial<ConversationListItemPropsType> = {}

View File

@ -16,13 +16,21 @@ import {
ContactListItem,
PropsDataType as ContactListItemPropsType,
} from './conversationList/ContactListItem';
import {
ContactCheckbox as ContactCheckboxComponent,
ContactCheckboxDisabledReason,
} from './conversationList/ContactCheckbox';
import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton';
import { Spinner as SpinnerComponent } from './Spinner';
import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation';
export enum RowType {
ArchiveButton,
Blank,
Contact,
ContactCheckbox,
Conversation,
CreateNewGroup,
Header,
MessageSearchResult,
Spinner,
@ -34,9 +42,19 @@ type ArchiveButtonRowType = {
archivedConversationsCount: number;
};
type BlankRowType = { type: RowType.Blank };
type ContactRowType = {
type: RowType.Contact;
contact: ContactListItemPropsType;
isClickable?: boolean;
};
type ContactCheckboxRowType = {
type: RowType.ContactCheckbox;
contact: ContactListItemPropsType;
isChecked: boolean;
disabledReason?: ContactCheckboxDisabledReason;
};
type ConversationRowType = {
@ -44,6 +62,10 @@ type ConversationRowType = {
conversation: ConversationListItemPropsType;
};
type CreateNewGroupRowType = {
type: RowType.CreateNewGroup;
};
type MessageRowType = {
type: RowType.MessageSearchResult;
messageId: string;
@ -63,8 +85,11 @@ type StartNewConversationRowType = {
export type Row =
| ArchiveButtonRowType
| BlankRowType
| ContactRowType
| ContactCheckboxRowType
| ConversationRowType
| CreateNewGroupRowType
| MessageRowType
| HeaderRowType
| SpinnerRowType
@ -85,9 +110,14 @@ export type PropsType = {
i18n: LocalizerType;
onSelectConversation: (conversationId: string, messageId?: string) => void;
onClickArchiveButton: () => void;
onClickContactCheckbox: (
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => void;
onSelectConversation: (conversationId: string, messageId?: string) => void;
renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element;
showChooseGroupMembers: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
};
@ -96,11 +126,13 @@ export const ConversationList: React.FC<PropsType> = ({
getRow,
i18n,
onClickArchiveButton,
onClickContactCheckbox,
onSelectConversation,
renderMessageSearchResult,
rowCount,
scrollToRowIndex,
shouldRecomputeRowHeights,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
}) => {
const listRef = useRef<null | List>(null);
@ -148,13 +180,29 @@ export const ConversationList: React.FC<PropsType> = ({
</span>
</button>
);
case RowType.Contact:
case RowType.Blank:
return <div key={key} style={style} />;
case RowType.Contact: {
const { isClickable = true } = row;
return (
<ContactListItem
{...row.contact}
key={key}
style={style}
onClick={onSelectConversation}
onClick={isClickable ? onSelectConversation : undefined}
i18n={i18n}
/>
);
}
case RowType.ContactCheckbox:
return (
<ContactCheckboxComponent
{...row.contact}
isChecked={row.isChecked}
disabledReason={row.disabledReason}
key={key}
style={style}
onClick={onClickContactCheckbox}
i18n={i18n}
/>
);
@ -168,6 +216,15 @@ export const ConversationList: React.FC<PropsType> = ({
i18n={i18n}
/>
);
case RowType.CreateNewGroup:
return (
<CreateNewGroupButton
i18n={i18n}
key={key}
onClick={showChooseGroupMembers}
style={style}
/>
);
case RowType.Header:
return (
<div
@ -214,8 +271,10 @@ export const ConversationList: React.FC<PropsType> = ({
getRow,
i18n,
onClickArchiveButton,
onClickContactCheckbox,
onSelectConversation,
renderMessageSearchResult,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
]
);

View File

@ -0,0 +1,120 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactChild, ReactNode } from 'react';
import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
import { ModalHost } from './ModalHost';
import { Button, ButtonVariant } from './Button';
import { Avatar, AvatarSize } from './Avatar';
import { ContactName } from './conversation/ContactName';
type PropsType = {
children: ReactNode;
i18n: LocalizerType;
onClickPrimaryButton: () => void;
onClose: () => void;
primaryButtonText: string;
title: string;
} & (
| // We use this empty type for an "all or nothing" setup.
// eslint-disable-next-line @typescript-eslint/ban-types
{}
| {
onClickSecondaryButton: () => void;
secondaryButtonText: string;
}
);
export function GroupDialog(props: Readonly<PropsType>): JSX.Element {
const {
children,
i18n,
onClickPrimaryButton,
onClose,
primaryButtonText,
title,
} = props;
let secondaryButton: undefined | ReactChild;
if ('secondaryButtonText' in props) {
const { onClickSecondaryButton, secondaryButtonText } = props;
secondaryButton = (
<Button
onClick={onClickSecondaryButton}
variant={ButtonVariant.Secondary}
>
{secondaryButtonText}
</Button>
);
}
return (
<ModalHost onClose={onClose}>
<div className="module-GroupDialog">
<button
aria-label={i18n('close')}
type="button"
className="module-GroupDialog__close-button"
onClick={() => {
onClose();
}}
/>
<h1 className="module-GroupDialog__title">{title}</h1>
<div className="module-GroupDialog__body">{children}</div>
<div className="module-GroupDialog__button-container">
{secondaryButton}
<Button
onClick={onClickPrimaryButton}
ref={focusRef}
variant={ButtonVariant.Primary}
>
{primaryButtonText}
</Button>
</div>
</div>
</ModalHost>
);
}
type ParagraphPropsType = {
children: ReactNode;
};
GroupDialog.Paragraph = ({
children,
}: Readonly<ParagraphPropsType>): JSX.Element => (
<p className="module-GroupDialog__paragraph">{children}</p>
);
type ContactsPropsType = {
contacts: Array<ConversationType>;
i18n: LocalizerType;
};
GroupDialog.Contacts = ({ contacts, i18n }: Readonly<ContactsPropsType>) => (
<ul className="module-GroupDialog__contacts">
{contacts.map(contact => (
<li key={contact.id} className="module-GroupDialog__contacts__contact">
<Avatar
{...contact}
conversationType={contact.type}
size={AvatarSize.TWENTY_EIGHT}
i18n={i18n}
/>
<ContactName
i18n={i18n}
module="module-GroupDialog__contacts__contact__name"
title={contact.title}
/>
</li>
))}
</ul>
);
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}

View File

@ -2,10 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
import { Avatar } from './Avatar';
import { GroupDialog } from './GroupDialog';
import { sortByTitle } from '../util/sortByTitle';
type CallbackType = () => unknown;
@ -25,61 +24,64 @@ export type HousekeepingPropsType = {
export type PropsType = DataPropsType & HousekeepingPropsType;
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}
export const GroupV1MigrationDialog: React.FunctionComponent<PropsType> = React.memo(
(props: PropsType) => {
const {
areWeInvited,
droppedMembers,
hasMigrated,
i18n,
invitedMembers,
migrate,
onClose,
} = props;
export const GroupV1MigrationDialog = React.memo((props: PropsType) => {
const {
areWeInvited,
droppedMembers,
hasMigrated,
i18n,
invitedMembers,
migrate,
onClose,
} = props;
const title = hasMigrated
? i18n('GroupV1--Migration--info--title')
: i18n('GroupV1--Migration--migrate--title');
const keepHistory = hasMigrated
? i18n('GroupV1--Migration--info--keep-history')
: i18n('GroupV1--Migration--migrate--keep-history');
const migrationKey = hasMigrated ? 'after' : 'before';
const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`;
const title = hasMigrated
? i18n('GroupV1--Migration--info--title')
: i18n('GroupV1--Migration--migrate--title');
const keepHistory = hasMigrated
? i18n('GroupV1--Migration--info--keep-history')
: i18n('GroupV1--Migration--migrate--keep-history');
const migrationKey = hasMigrated ? 'after' : 'before';
const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`;
let primaryButtonText: string;
let onClickPrimaryButton: () => void;
let secondaryButtonProps:
| undefined
| {
secondaryButtonText: string;
onClickSecondaryButton: () => void;
};
if (hasMigrated) {
primaryButtonText = i18n('Confirmation--confirm');
onClickPrimaryButton = onClose;
} else {
primaryButtonText = i18n('GroupV1--Migration--migrate');
onClickPrimaryButton = migrate;
secondaryButtonProps = {
secondaryButtonText: i18n('cancel'),
onClickSecondaryButton: onClose,
};
}
return (
<div className="module-group-v2-migration-dialog">
<button
aria-label={i18n('close')}
type="button"
className="module-group-v2-migration-dialog__close-button"
onClick={onClose}
/>
<div className="module-group-v2-migration-dialog__title">{title}</div>
<div className="module-group-v2-migration-dialog__scrollable">
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
{i18n('GroupV1--Migration--info--summary')}
</div>
</div>
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
{keepHistory}
</div>
</div>
return (
<GroupDialog
i18n={i18n}
onClickPrimaryButton={onClickPrimaryButton}
onClose={onClose}
primaryButtonText={primaryButtonText}
title={title}
{...secondaryButtonProps}
>
<GroupDialog.Paragraph>
{i18n('GroupV1--Migration--info--summary')}
</GroupDialog.Paragraph>
<GroupDialog.Paragraph>{keepHistory}</GroupDialog.Paragraph>
{areWeInvited ? (
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
{i18n('GroupV1--Migration--info--invited--you')}
</div>
</div>
<GroupDialog.Paragraph>
{i18n('GroupV1--Migration--info--invited--you')}
</GroupDialog.Paragraph>
) : (
<>
{renderMembers(
@ -90,67 +92,16 @@ export const GroupV1MigrationDialog = React.memo((props: PropsType) => {
{renderMembers(droppedMembers, droppedMembersKey, i18n)}
</>
)}
</div>
{renderButtons(hasMigrated, onClose, migrate, i18n)}
</div>
);
});
function renderButtons(
hasMigrated: boolean,
onClose: CallbackType,
migrate: CallbackType,
i18n: LocalizerType
) {
if (hasMigrated) {
return (
<div
className={classNames(
'module-group-v2-migration-dialog__buttons',
'module-group-v2-migration-dialog__buttons--narrow'
)}
>
<button
className="module-group-v2-migration-dialog__button"
ref={focusRef}
type="button"
onClick={onClose}
>
{i18n('Confirmation--confirm')}
</button>
</div>
</GroupDialog>
);
}
return (
<div className="module-group-v2-migration-dialog__buttons">
<button
className={classNames(
'module-group-v2-migration-dialog__button',
'module-group-v2-migration-dialog__button--secondary'
)}
type="button"
onClick={onClose}
>
{i18n('cancel')}
</button>
<button
className="module-group-v2-migration-dialog__button"
ref={focusRef}
type="button"
onClick={migrate}
>
{i18n('GroupV1--Migration--migrate')}
</button>
</div>
);
}
);
function renderMembers(
members: Array<ConversationType>,
prefix: string,
i18n: LocalizerType
): React.ReactElement | null {
): React.ReactNode {
if (!members.length) {
return null;
}
@ -159,27 +110,9 @@ function renderMembers(
const key = `${prefix}${postfix}`;
return (
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
<div>{i18n(key)}</div>
{sortByTitle(members).map(member => (
<div
key={member.id}
className="module-group-v2-migration-dialog__member"
>
<Avatar
{...member}
conversationType={member.type}
size={28}
i18n={i18n}
/>{' '}
<span className="module-group-v2-migration-dialog__member__name">
{member.title}
</span>
</div>
))}
</div>
</div>
<>
<GroupDialog.Paragraph>{i18n(key)}</GroupDialog.Paragraph>
<GroupDialog.Contacts contacts={sortByTitle(members)} i18n={i18n} />
</>
);
}

View File

@ -77,6 +77,12 @@ const defaultModeSpecificProps = {
const emptySearchResultsGroup = { isLoading: false, results: [] };
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
cantAddContactToGroup: action('cantAddContactToGroup'),
clearGroupCreationError: action('clearGroupCreationError'),
closeCantAddContactToGroupModal: action('closeCantAddContactToGroupModal'),
closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'),
closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'),
createGroup: action('createGroup'),
i18n,
modeSpecificProps: defaultModeSpecificProps,
openConversationInternal: action('openConversationInternal'),
@ -102,12 +108,19 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
selectedConversationId: undefined,
selectedMessageId: undefined,
setComposeSearchTerm: action('setComposeSearchTerm'),
setComposeGroupAvatar: action('setComposeGroupAvatar'),
setComposeGroupName: action('setComposeGroupName'),
showArchivedConversations: action('showArchivedConversations'),
showInbox: action('showInbox'),
startComposing: action('startComposing'),
showChooseGroupMembers: action('showChooseGroupMembers'),
startNewConversationFromPhoneNumber: action(
'startNewConversationFromPhoneNumber'
),
startSettingGroupMetadata: action('startSettingGroupMetadata'),
toggleConversationInChooseMembers: action(
'toggleConversationInChooseMembers'
),
...overrideProps,
});

View File

@ -26,18 +26,29 @@ import {
LeftPaneComposeHelper,
LeftPaneComposePropsType,
} from './leftPane/LeftPaneComposeHelper';
import {
LeftPaneChooseGroupMembersHelper,
LeftPaneChooseGroupMembersPropsType,
} from './leftPane/LeftPaneChooseGroupMembersHelper';
import {
LeftPaneSetGroupMetadataHelper,
LeftPaneSetGroupMetadataPropsType,
} from './leftPane/LeftPaneSetGroupMetadataHelper';
import * as OS from '../OS';
import { LocalizerType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError';
import { ConversationList } from './ConversationList';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
export enum LeftPaneMode {
Inbox,
Search,
Archive,
Compose,
ChooseGroupMembers,
SetGroupMetadata,
}
export type PropsType = {
@ -56,23 +67,40 @@ export type PropsType = {
} & LeftPaneArchivePropsType)
| ({
mode: LeftPaneMode.Compose;
} & LeftPaneComposePropsType);
} & LeftPaneComposePropsType)
| ({
mode: LeftPaneMode.ChooseGroupMembers;
} & LeftPaneChooseGroupMembersPropsType)
| ({
mode: LeftPaneMode.SetGroupMetadata;
} & LeftPaneSetGroupMetadataPropsType);
i18n: LocalizerType;
selectedConversationId: undefined | string;
selectedMessageId: undefined | string;
regionCode: string;
// Action Creators
cantAddContactToGroup: (conversationId: string) => void;
clearGroupCreationError: () => void;
closeCantAddContactToGroupModal: () => void;
closeMaximumGroupSizeModal: () => void;
closeRecommendedGroupSizeModal: () => void;
createGroup: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
openConversationInternal: (_: {
conversationId: string;
messageId?: string;
switchToAssociatedView?: boolean;
}) => void;
setComposeSearchTerm: (composeSearchTerm: string) => void;
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => void;
setComposeGroupName: (_: string) => void;
showArchivedConversations: () => void;
showInbox: () => void;
startComposing: () => void;
setComposeSearchTerm: (composeSearchTerm: string) => void;
showChooseGroupMembers: () => void;
startSettingGroupMetadata: () => void;
toggleConversationInChooseMembers: (conversationId: string) => void;
// Render Props
renderExpiredBuildDialog: () => JSX.Element;
@ -84,6 +112,12 @@ export type PropsType = {
};
export const LeftPane: React.FC<PropsType> = ({
cantAddContactToGroup,
clearGroupCreationError,
closeCantAddContactToGroupModal,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
createGroup,
i18n,
modeSpecificProps,
openConversationInternal,
@ -96,10 +130,15 @@ export const LeftPane: React.FC<PropsType> = ({
selectedConversationId,
selectedMessageId,
setComposeSearchTerm,
setComposeGroupAvatar,
setComposeGroupName,
showArchivedConversations,
showInbox,
startComposing,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
startSettingGroupMetadata,
toggleConversationInChooseMembers,
}) => {
const previousModeSpecificPropsRef = useRef(modeSpecificProps);
const previousModeSpecificProps = previousModeSpecificPropsRef.current;
@ -162,6 +201,32 @@ export const LeftPane: React.FC<PropsType> = ({
helper = composeHelper;
break;
}
case LeftPaneMode.ChooseGroupMembers: {
const chooseGroupMembersHelper = new LeftPaneChooseGroupMembersHelper(
modeSpecificProps
);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? chooseGroupMembersHelper.shouldRecomputeRowHeights(
previousModeSpecificProps
)
: true;
helper = chooseGroupMembersHelper;
break;
}
case LeftPaneMode.SetGroupMetadata: {
const setGroupMetadataHelper = new LeftPaneSetGroupMetadataHelper(
modeSpecificProps
);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? setGroupMetadataHelper.shouldRecomputeRowHeights(
previousModeSpecificProps
)
: true;
helper = setGroupMetadataHelper;
break;
}
default:
throw missingCaseError(modeSpecificProps);
}
@ -245,11 +310,25 @@ export const LeftPane: React.FC<PropsType> = ({
]);
const preRowsNode = helper.getPreRowsNode({
clearGroupCreationError,
closeCantAddContactToGroupModal,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
createGroup,
i18n,
setComposeGroupAvatar,
setComposeGroupName,
onChangeComposeSearchTerm: event => {
setComposeSearchTerm(event.target.value);
},
removeSelectedContact: toggleConversationInChooseMembers,
});
const footerContents = helper.getFooterContents({
createGroup,
i18n,
startSettingGroupMetadata,
});
const getRow = useMemo(() => helper.getRow.bind(helper), [helper]);
// We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring
@ -261,7 +340,12 @@ export const LeftPane: React.FC<PropsType> = ({
return (
<div className="module-left-pane">
<div className="module-left-pane__header">
{helper.getHeaderContents({ i18n, showInbox }) || renderMainHeader()}
{helper.getHeaderContents({
i18n,
showInbox,
startComposing,
showChooseGroupMembers,
}) || renderMainHeader()}
</div>
{renderExpiredBuildDialog()}
{renderRelinkDialog()}
@ -288,6 +372,24 @@ export const LeftPane: React.FC<PropsType> = ({
getRow={getRow}
i18n={i18n}
onClickArchiveButton={showArchivedConversations}
onClickContactCheckbox={(
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => {
switch (disabledReason) {
case undefined:
toggleConversationInChooseMembers(conversationId);
break;
case ContactCheckboxDisabledReason.MaximumContactsSelected:
// This is a no-op.
break;
case ContactCheckboxDisabledReason.NotCapable:
cantAddContactToGroup(conversationId);
break;
default:
throw missingCaseError(disabledReason);
}
}}
onSelectConversation={(
conversationId: string,
messageId?: string
@ -304,6 +406,7 @@ export const LeftPane: React.FC<PropsType> = ({
selectedConversationId
)}
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
showChooseGroupMembers={showChooseGroupMembers}
startNewConversationFromPhoneNumber={
startNewConversationFromPhoneNumber
}
@ -313,6 +416,9 @@ export const LeftPane: React.FC<PropsType> = ({
</div>
)}
</Measure>
{footerContents && (
<div className="module-left-pane__footer">{footerContents}</div>
)}
</div>
);
};

View File

@ -0,0 +1,54 @@
// 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 { NewlyCreatedGroupInvitedContactsDialog } from './NewlyCreatedGroupInvitedContactsDialog';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { ConversationType } from '../state/ducks/conversations';
const i18n = setupI18n('en', enMessages);
const conversations: Array<ConversationType> = [
{
id: 'fred-convo',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Fred Willard',
type: 'direct',
},
{
id: 'marc-convo',
isSelected: true,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Marc Barraca',
type: 'direct',
},
];
const story = storiesOf(
'Components/NewlyCreatedGroupInvitedContactsDialog',
module
);
story.add('One contact', () => (
<NewlyCreatedGroupInvitedContactsDialog
contacts={[conversations[0]]}
i18n={i18n}
onClose={action('onClose')}
/>
));
story.add('Two contacts', () => (
<NewlyCreatedGroupInvitedContactsDialog
contacts={conversations}
i18n={i18n}
onClose={action('onClose')}
/>
));

View File

@ -0,0 +1,80 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent, ReactNode } from 'react';
import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
import { Intl } from './Intl';
import { ContactName } from './conversation/ContactName';
import { GroupDialog } from './GroupDialog';
type PropsType = {
contacts: Array<ConversationType>;
i18n: LocalizerType;
onClose: () => void;
};
export const NewlyCreatedGroupInvitedContactsDialog: FunctionComponent<PropsType> = ({
contacts,
i18n,
onClose,
}) => {
let title: string;
let body: ReactNode;
if (contacts.length === 1) {
const contact = contacts[0];
title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--one');
body = (
<>
<GroupDialog.Paragraph>
<Intl
i18n={i18n}
id="NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--one"
components={[<ContactName i18n={i18n} title={contact.title} />]}
/>
</GroupDialog.Paragraph>
<GroupDialog.Paragraph>
{i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')}
</GroupDialog.Paragraph>
</>
);
} else {
title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--many', [
contacts.length.toString(),
]);
body = (
<>
<GroupDialog.Paragraph>
{i18n(
'NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--many'
)}
</GroupDialog.Paragraph>
<GroupDialog.Paragraph>
{i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')}
</GroupDialog.Paragraph>
<GroupDialog.Contacts contacts={contacts} i18n={i18n} />
</>
);
}
return (
<GroupDialog
i18n={i18n}
onClickPrimaryButton={onClose}
primaryButtonText={i18n('Confirmation--confirm')}
secondaryButtonText={i18n(
'NewlyCreatedGroupInvitedContactsDialog--body--learn-more'
)}
onClickSecondaryButton={() => {
window.location.href =
'https://support.signal.org/hc/articles/360007319331-Group-chats';
}}
onClose={onClose}
title={title}
>
{body}
</GroupDialog>
);
};

View File

@ -7,20 +7,34 @@ import { LocalizerType } from '../../types/Util';
import { Emojify } from './Emojify';
export type PropsType = {
firstName?: string;
i18n: LocalizerType;
title: string;
module?: string;
name?: string;
phoneNumber?: string;
preferFirstName?: boolean;
profileName?: string;
title: string;
};
export const ContactName = ({ module, title }: PropsType): JSX.Element => {
export const ContactName = ({
firstName,
module,
preferFirstName,
title,
}: PropsType): JSX.Element => {
const prefix = module || 'module-contact-name';
let text: string;
if (preferFirstName) {
text = firstName || title || '';
} else {
text = title || '';
}
return (
<span className={prefix} dir="auto">
<Emojify text={title || ''} />
<Emojify text={text} />
</span>
);
};

View File

@ -7,7 +7,6 @@ import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
import { Intl } from '../Intl';
import { ContactName } from './ContactName';
import { ModalHost } from '../ModalHost';
import { GroupV1MigrationDialog } from '../GroupV1MigrationDialog';
export type PropsDataType = {
@ -58,19 +57,17 @@ export function GroupV1Migration(props: PropsType): React.ReactElement {
{i18n('GroupV1--Migration--learn-more')}
</button>
{showingDialog ? (
<ModalHost onClose={dismissDialog}>
<GroupV1MigrationDialog
areWeInvited={areWeInvited}
droppedMembers={droppedMembers}
hasMigrated
i18n={i18n}
invitedMembers={invitedMembers}
migrate={() =>
window.log.warn('GroupV1Migration: Modal called migrate()')
}
onClose={dismissDialog}
/>
</ModalHost>
<GroupV1MigrationDialog
areWeInvited={areWeInvited}
droppedMembers={droppedMembers}
hasMigrated
i18n={i18n}
invitedMembers={invitedMembers}
migrate={() =>
window.log.warn('GroupV1Migration: Modal called migrate()')
}
onClose={dismissDialog}
/>
) : null}
</div>
);

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -211,6 +211,9 @@ const items: Record<string, TimelineItemType> = {
const actions = () => ({
clearChangedMessages: action('clearChangedMessages'),
clearInvitedConversationsForNewlyCreatedGroup: action(
'clearInvitedConversationsForNewlyCreatedGroup'
),
setLoadCountdownStart: action('setLoadCountdownStart'),
setIsNearBottom: action('setIsNearBottom'),
loadAndScroll: action('loadAndScroll'),
@ -299,6 +302,8 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
oldestUnreadIndex:
number('oldestUnreadIndex', overrideProps.oldestUnreadIndex || 0) ||
undefined,
invitedContactsForNewlyCreatedGroup:
overrideProps.invitedContactsForNewlyCreatedGroup || [],
id: '',
renderItem,
@ -361,3 +366,22 @@ story.add('Without Oldest Message', () => {
return <Timeline {...props} />;
});
story.add('With invited contacts for a newly-created group', () => {
const props = createProps({
invitedContactsForNewlyCreatedGroup: [
{
id: 'abc123',
title: 'John Bon Bon Jovi',
type: 'direct',
},
{
id: 'def456',
title: 'Bon John Bon Jovi',
type: 'direct',
},
],
});
return <Timeline {...props} />;
});

View File

@ -15,9 +15,11 @@ import {
import { ScrollDownButton } from './ScrollDownButton';
import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
import { PropsActions as MessageActionsType } from './Message';
import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification';
import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog';
const AT_BOTTOM_THRESHOLD = 15;
const NEAR_BOTTOM_THRESHOLD = 15;
@ -48,6 +50,7 @@ type PropsHousekeepingType = {
isGroupV1AndDisabled?: boolean;
selectedMessageId?: string;
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
i18n: LocalizerType;
@ -68,6 +71,7 @@ type PropsHousekeepingType = {
type PropsActionsType = {
clearChangedMessages: (conversationId: string) => unknown;
clearInvitedConversationsForNewlyCreatedGroup: () => void;
setLoadCountdownStart: (
conversationId: string,
loadCountdownStart?: number
@ -1063,7 +1067,14 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
};
public render(): JSX.Element | null {
const { i18n, id, items, isGroupV1AndDisabled } = this.props;
const {
clearInvitedConversationsForNewlyCreatedGroup,
i18n,
id,
items,
isGroupV1AndDisabled,
invitedContactsForNewlyCreatedGroup,
} = this.props;
const {
shouldShowScrollDownButton,
areUnreadBelowCurrentPosition,
@ -1077,60 +1088,70 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
}
return (
<div
className={classNames(
'module-timeline',
isGroupV1AndDisabled ? 'module-timeline--disabled' : null
)}
role="presentation"
tabIndex={-1}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
>
<AutoSizer>
{({ height, width }) => {
if (this.mostRecentWidth && this.mostRecentWidth !== width) {
this.resizeFlag = true;
<>
<div
className={classNames(
'module-timeline',
isGroupV1AndDisabled ? 'module-timeline--disabled' : null
)}
role="presentation"
tabIndex={-1}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
>
<AutoSizer>
{({ height, width }) => {
if (this.mostRecentWidth && this.mostRecentWidth !== width) {
this.resizeFlag = true;
setTimeout(this.resize, 0);
} else if (
this.mostRecentHeight &&
this.mostRecentHeight !== height
) {
setTimeout(this.onHeightOnlyChange, 0);
}
setTimeout(this.resize, 0);
} else if (
this.mostRecentHeight &&
this.mostRecentHeight !== height
) {
setTimeout(this.onHeightOnlyChange, 0);
}
this.mostRecentWidth = width;
this.mostRecentHeight = height;
this.mostRecentWidth = width;
this.mostRecentHeight = height;
return (
<List
deferredMeasurementCache={this.cellSizeCache}
height={height}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScroll={this.onScroll as any}
overscanRowCount={10}
ref={this.listRef}
rowCount={rowCount}
rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.rowRenderer}
scrollToAlignment="start"
scrollToIndex={scrollToIndex}
tabIndex={-1}
width={width}
/>
);
}}
</AutoSizer>
{shouldShowScrollDownButton ? (
<ScrollDownButton
conversationId={id}
withNewMessages={areUnreadBelowCurrentPosition}
scrollDown={this.onClickScrollDownButton}
return (
<List
deferredMeasurementCache={this.cellSizeCache}
height={height}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScroll={this.onScroll as any}
overscanRowCount={10}
ref={this.listRef}
rowCount={rowCount}
rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.rowRenderer}
scrollToAlignment="start"
scrollToIndex={scrollToIndex}
tabIndex={-1}
width={width}
/>
);
}}
</AutoSizer>
{shouldShowScrollDownButton ? (
<ScrollDownButton
conversationId={id}
withNewMessages={areUnreadBelowCurrentPosition}
scrollDown={this.onClickScrollDownButton}
i18n={i18n}
/>
) : null}
</div>
{Boolean(invitedContactsForNewlyCreatedGroup.length) && (
<NewlyCreatedGroupInvitedContactsDialog
contacts={invitedContactsForNewlyCreatedGroup}
i18n={i18n}
onClose={clearInvitedConversationsForNewlyCreatedGroup}
/>
) : null}
</div>
)}
</>
);
}
}

View File

@ -20,11 +20,14 @@ export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`;
const TIMESTAMP_CLASS_NAME = `${DATE_CLASS_NAME}__timestamp`;
export const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`;
export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
type PropsType = {
avatarPath?: string;
checked?: boolean;
color?: ColorType;
conversationType: 'group' | 'direct';
disabled?: boolean;
headerDate?: number;
headerName: ReactNode;
i18n: LocalizerType;
@ -37,7 +40,7 @@ type PropsType = {
messageStatusIcon?: ReactNode;
messageText?: ReactNode;
name?: string;
onClick: () => void;
onClick?: () => void;
phoneNumber?: string;
profileName?: string;
style: CSSProperties;
@ -48,8 +51,10 @@ type PropsType = {
export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo(
({
avatarPath,
checked,
color,
conversationType,
disabled,
headerDate,
headerName,
i18n,
@ -74,17 +79,32 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
? isNoteToSelf
: Boolean(isMe);
return (
<button
type="button"
onClick={onClick}
style={style}
className={classNames(BASE_CLASS_NAME, {
[`${BASE_CLASS_NAME}--has-unread`]: isUnread,
[`${BASE_CLASS_NAME}--is-selected`]: isSelected,
})}
data-id={id ? cleanId(id) : undefined}
>
const isCheckbox = isBoolean(checked);
let checkboxNode: ReactNode;
if (isCheckbox) {
let ariaLabel: string;
if (disabled) {
ariaLabel = i18n('cannotSelectContact');
} else if (checked) {
ariaLabel = i18n('deselectContact');
} else {
ariaLabel = i18n('selectContact');
}
checkboxNode = (
<input
aria-label={ariaLabel}
checked={checked}
className={CHECKBOX_CLASS_NAME}
disabled={disabled}
onChange={onClick}
type="checkbox"
/>
);
}
const contents = (
<>
<div className={`${BASE_CLASS_NAME}__avatar-container`}>
<Avatar
avatarPath={avatarPath}
@ -104,7 +124,12 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
</div>
)}
</div>
<div className={CONTENT_CLASS_NAME}>
<div
className={classNames(
CONTENT_CLASS_NAME,
disabled && `${CONTENT_CLASS_NAME}--disabled`
)}
>
<div className={HEADER_CLASS_NAME}>
<div className={`${HEADER_CLASS_NAME}__name`}>{headerName}</div>
{isNumber(headerDate) && (
@ -137,7 +162,61 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
</div>
) : null}
</div>
</button>
{checkboxNode}
</>
);
const commonClassNames = classNames(BASE_CLASS_NAME, {
[`${BASE_CLASS_NAME}--has-unread`]: isUnread,
[`${BASE_CLASS_NAME}--is-selected`]: isSelected,
});
if (isCheckbox) {
return (
<label
className={classNames(
commonClassNames,
`${BASE_CLASS_NAME}--is-checkbox`,
{ [`${BASE_CLASS_NAME}--is-checkbox--disabled`]: disabled }
)}
data-id={id ? cleanId(id) : undefined}
style={style}
// `onClick` is will double-fire if we're enabled. We want it to fire when we're
// disabled so we can show any "can't add contact" modals, etc. This won't
// work for keyboard users, though, because labels are not tabbable.
{...(disabled ? { onClick } : {})}
>
{contents}
</label>
);
}
if (onClick) {
return (
<button
className={classNames(
commonClassNames,
`${BASE_CLASS_NAME}--is-button`
)}
data-id={id ? cleanId(id) : undefined}
disabled={disabled}
onClick={onClick}
style={style}
type="button"
>
{contents}
</button>
);
}
return (
<div
className={commonClassNames}
data-id={id ? cleanId(id) : undefined}
style={style}
>
{contents}
</div>
);
}
);

View File

@ -0,0 +1,97 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import { ColorType } from '../../types/Colors';
import { LocalizerType } from '../../types/Util';
import { ContactName } from '../conversation/ContactName';
import { About } from '../conversation/About';
export enum ContactCheckboxDisabledReason {
// We start the enum at 1 because the default starting value of 0 is falsy.
MaximumContactsSelected = 1,
NotCapable,
}
export type PropsDataType = {
about?: string;
avatarPath?: string;
color?: ColorType;
disabledReason?: ContactCheckboxDisabledReason;
id: string;
isChecked: boolean;
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
};
type PropsHousekeepingType = {
i18n: LocalizerType;
style: CSSProperties;
onClick: (
id: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => void;
};
type PropsType = PropsDataType & PropsHousekeepingType;
export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
({
about,
avatarPath,
color,
disabledReason,
i18n,
id,
isChecked,
name,
onClick,
phoneNumber,
profileName,
style,
title,
}) => {
const disabled = Boolean(disabledReason);
const headerName = (
<ContactName
phoneNumber={phoneNumber}
name={name}
profileName={profileName}
title={title}
i18n={i18n}
/>
);
const messageText = about ? <About className="" text={about} /> : null;
const onClickItem = () => {
onClick(id, disabledReason);
};
return (
<BaseConversationListItem
avatarPath={avatarPath}
checked={isChecked}
color={color}
conversationType="direct"
disabled={disabled}
headerName={headerName}
i18n={i18n}
id={id}
isSelected={false}
messageText={messageText}
name={name}
onClick={onClickItem}
phoneNumber={phoneNumber}
profileName={profileName}
style={style}
title={title}
/>
);
}
);

View File

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, CSSProperties, FunctionComponent } from 'react';
import React, { CSSProperties, FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import { ColorType } from '../../types/Colors';
@ -25,7 +25,7 @@ export type PropsDataType = {
type PropsHousekeepingType = {
i18n: LocalizerType;
style: CSSProperties;
onClick: (id: string) => void;
onClick?: (id: string) => void;
};
type PropsType = PropsDataType & PropsHousekeepingType;
@ -61,8 +61,6 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
const messageText =
about && !isMe ? <About className="" text={about} /> : null;
const onClickItem = useCallback(() => onClick(id), [onClick, id]);
return (
<BaseConversationListItem
avatarPath={avatarPath}
@ -75,7 +73,7 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
isSelected={false}
messageText={messageText}
name={name}
onClick={onClickItem}
onClick={onClick ? () => onClick(id) : undefined}
phoneNumber={phoneNumber}
profileName={profileName}
style={style}

View File

@ -0,0 +1,32 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import { LocalizerType } from '../../types/Util';
type PropsType = {
i18n: LocalizerType;
onClick: () => void;
style: CSSProperties;
};
export const CreateNewGroupButton: FunctionComponent<PropsType> = React.memo(
({ i18n, onClick, style }) => {
const title = i18n('createNewGroupButton');
return (
<BaseConversationListItem
color="grey"
conversationType="group"
headerName={title}
i18n={i18n}
isSelected={false}
onClick={onClick}
style={style}
title={title}
/>
);
}
);

View File

@ -0,0 +1,304 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactChild, ChangeEvent } from 'react';
import { LeftPaneHelper } from './LeftPaneHelper';
import { Row, RowType } from '../ConversationList';
import { ConversationType } from '../../state/ducks/conversations';
import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox';
import { ContactPills } from '../ContactPills';
import { ContactPill } from '../ContactPill';
import { Alert } from '../Alert';
import { Button } from '../Button';
import { LocalizerType } from '../../types/Util';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../groups/limits';
export type LeftPaneChooseGroupMembersPropsType = {
candidateContacts: ReadonlyArray<ConversationType>;
cantAddContactForModal: undefined | ConversationType;
isShowingRecommendedGroupSizeModal: boolean;
isShowingMaximumGroupSizeModal: boolean;
searchTerm: string;
selectedContacts: Array<ConversationType>;
};
/* eslint-disable class-methods-use-this */
export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<
LeftPaneChooseGroupMembersPropsType
> {
private readonly candidateContacts: ReadonlyArray<ConversationType>;
private readonly cantAddContactForModal:
| undefined
| Readonly<{ title: string }>;
private readonly isShowingMaximumGroupSizeModal: boolean;
private readonly isShowingRecommendedGroupSizeModal: boolean;
private readonly searchTerm: string;
private readonly selectedContacts: Array<ConversationType>;
private readonly selectedConversationIdsSet: Set<string>;
constructor({
candidateContacts,
cantAddContactForModal,
isShowingMaximumGroupSizeModal,
isShowingRecommendedGroupSizeModal,
searchTerm,
selectedContacts,
}: Readonly<LeftPaneChooseGroupMembersPropsType>) {
super();
this.candidateContacts = candidateContacts;
this.cantAddContactForModal = cantAddContactForModal;
this.isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal;
this.isShowingRecommendedGroupSizeModal = isShowingRecommendedGroupSizeModal;
this.searchTerm = searchTerm;
this.selectedContacts = selectedContacts;
this.selectedConversationIdsSet = new Set(
selectedContacts.map(contact => contact.id)
);
}
getHeaderContents({
i18n,
startComposing,
}: Readonly<{
i18n: LocalizerType;
startComposing: () => void;
}>): ReactChild {
const backButtonLabel = i18n('chooseGroupMembers__back-button');
return (
<div className="module-left-pane__header__contents">
<button
aria-label={backButtonLabel}
className="module-left-pane__header__contents__back-button"
onClick={startComposing}
title={backButtonLabel}
type="button"
/>
<div className="module-left-pane__header__contents__text">
{i18n('chooseGroupMembers__title')}
</div>
</div>
);
}
getPreRowsNode({
closeCantAddContactToGroupModal,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
i18n,
onChangeComposeSearchTerm,
removeSelectedContact,
}: Readonly<{
closeCantAddContactToGroupModal: () => unknown;
closeMaximumGroupSizeModal: () => unknown;
closeRecommendedGroupSizeModal: () => unknown;
i18n: LocalizerType;
onChangeComposeSearchTerm: (
event: ChangeEvent<HTMLInputElement>
) => unknown;
removeSelectedContact: (conversationId: string) => unknown;
}>): ReactChild {
let modalDetails:
| undefined
| { title: string; body: string; onClose: () => void };
if (this.isShowingMaximumGroupSizeModal) {
modalDetails = {
title: i18n('chooseGroupMembers__maximum-group-size__title'),
body: i18n('chooseGroupMembers__maximum-group-size__body', [
this.getMaximumNumberOfContacts().toString(),
]),
onClose: closeMaximumGroupSizeModal,
};
} else if (this.isShowingRecommendedGroupSizeModal) {
modalDetails = {
title: i18n(
'chooseGroupMembers__maximum-recommended-group-size__title'
),
body: i18n('chooseGroupMembers__maximum-recommended-group-size__body', [
this.getRecommendedMaximumNumberOfContacts().toString(),
]),
onClose: closeRecommendedGroupSizeModal,
};
} else if (this.cantAddContactForModal) {
modalDetails = {
title: i18n('chooseGroupMembers__cant-add-member__title'),
body: i18n('chooseGroupMembers__cant-add-member__body', [
this.cantAddContactForModal.title,
]),
onClose: closeCantAddContactToGroupModal,
};
}
return (
<>
<div className="module-left-pane__compose-search-form">
<input
type="text"
ref={focusRef}
className="module-left-pane__compose-search-form__input"
placeholder={i18n('newConversationContactSearchPlaceholder')}
dir="auto"
value={this.searchTerm}
onChange={onChangeComposeSearchTerm}
/>
</div>
{Boolean(this.selectedContacts.length) && (
<ContactPills>
{this.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}
/>
))}
</ContactPills>
)}
{this.getRowCount() ? null : (
<div className="module-left-pane__compose-no-contacts">
{i18n('newConversationNoContacts')}
</div>
)}
{modalDetails && (
<Alert
body={modalDetails.body}
i18n={i18n}
onClose={modalDetails.onClose}
title={modalDetails.title}
/>
)}
</>
);
}
getFooterContents({
i18n,
startSettingGroupMetadata,
}: Readonly<{
i18n: LocalizerType;
startSettingGroupMetadata: () => void;
}>): ReactChild {
return (
<Button
disabled={this.hasExceededMaximumNumberOfContacts()}
onClick={startSettingGroupMetadata}
>
{this.selectedContacts.length
? i18n('chooseGroupMembers__next')
: i18n('chooseGroupMembers__skip')}
</Button>
);
}
getRowCount(): number {
if (!this.candidateContacts.length) {
return 0;
}
return this.candidateContacts.length + 2;
}
getRow(rowIndex: number): undefined | Row {
if (!this.candidateContacts.length) {
return undefined;
}
if (rowIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'contactsHeader',
};
}
// This puts a blank row for the footer.
if (rowIndex === this.candidateContacts.length + 1) {
return { type: RowType.Blank };
}
const contact = this.candidateContacts[rowIndex - 1];
if (!contact) {
return undefined;
}
const isChecked = this.selectedConversationIdsSet.has(contact.id);
let disabledReason: undefined | ContactCheckboxDisabledReason;
if (!isChecked) {
if (this.hasSelectedMaximumNumberOfContacts()) {
disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected;
} else if (!contact.isGroupV2Capable) {
disabledReason = ContactCheckboxDisabledReason.NotCapable;
}
}
return {
type: RowType.ContactCheckbox,
contact,
isChecked,
disabledReason,
};
}
// This is deliberately unimplemented because these keyboard shortcuts shouldn't work in
// the composer. The same is true for the "in direction" function below.
getConversationAndMessageAtIndex(
..._args: ReadonlyArray<unknown>
): undefined {
return undefined;
}
getConversationAndMessageInDirection(
..._args: ReadonlyArray<unknown>
): undefined {
return undefined;
}
shouldRecomputeRowHeights(_old: unknown): boolean {
return false;
}
private hasSelectedMaximumNumberOfContacts(): boolean {
return this.selectedContacts.length >= this.getMaximumNumberOfContacts();
}
private hasExceededMaximumNumberOfContacts(): boolean {
// It should be impossible to reach this state. This is here as a failsafe.
return this.selectedContacts.length > this.getMaximumNumberOfContacts();
}
private getRecommendedMaximumNumberOfContacts(): number {
return getGroupSizeRecommendedLimit(151) - 1;
}
private getMaximumNumberOfContacts(): number {
return getGroupSizeHardLimit(1001) - 1;
}
}
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}

View File

@ -12,6 +12,9 @@ import {
instance as phoneNumberInstance,
PhoneNumberFormat,
} from '../../util/libphonenumberInstance';
import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import { isStorageWriteFeatureEnabled } from '../../storage/isFeatureEnabled';
export type LeftPaneComposePropsType = {
composeContacts: ReadonlyArray<ContactListItemPropsType>;
@ -19,6 +22,12 @@ export type LeftPaneComposePropsType = {
searchTerm: string;
};
enum TopButton {
None,
CreateNewGroup,
StartNewConversation,
}
/* eslint-disable class-methods-use-this */
export class LeftPaneComposeHelper extends LeftPaneHelper<
@ -98,24 +107,53 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
}
getRowCount(): number {
return this.composeContacts.length + (this.phoneNumber ? 1 : 0);
let result = this.composeContacts.length;
if (this.hasTopButton()) {
result += 1;
}
if (this.hasContactsHeader()) {
result += 1;
}
return result;
}
getRow(rowIndex: number): undefined | Row {
let contactIndex = rowIndex;
if (this.phoneNumber) {
if (rowIndex === 0) {
return {
type: RowType.StartNewConversation,
phoneNumber: phoneNumberInstance.format(
if (rowIndex === 0) {
const topButton = this.getTopButton();
switch (topButton) {
case TopButton.None:
break;
case TopButton.StartNewConversation:
assert(
this.phoneNumber,
PhoneNumberFormat.E164
),
};
'LeftPaneComposeHelper: we should have a phone number if the top button is "Start new conversation"'
);
return {
type: RowType.StartNewConversation,
phoneNumber: phoneNumberInstance.format(
this.phoneNumber,
PhoneNumberFormat.E164
),
};
case TopButton.CreateNewGroup:
return { type: RowType.CreateNewGroup };
default:
throw missingCaseError(topButton);
}
}
contactIndex -= 1;
if (rowIndex === 1 && this.hasContactsHeader()) {
return {
type: RowType.Header,
i18nKey: 'contactsHeader',
};
}
let contactIndex: number;
if (this.hasTopButton()) {
contactIndex = rowIndex - 2;
} else {
contactIndex = rowIndex;
}
const contact = this.composeContacts[contactIndex];
@ -141,8 +179,29 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
return undefined;
}
shouldRecomputeRowHeights(_old: unknown): boolean {
return false;
shouldRecomputeRowHeights(old: Readonly<LeftPaneComposePropsType>): boolean {
return (
this.hasContactsHeader() !==
new LeftPaneComposeHelper(old).hasContactsHeader()
);
}
private getTopButton(): TopButton {
if (this.phoneNumber) {
return TopButton.StartNewConversation;
}
if (this.searchTerm || !isStorageWriteFeatureEnabled()) {
return TopButton.None;
}
return TopButton.CreateNewGroup;
}
private hasTopButton(): boolean {
return this.getTopButton() !== TopButton.None;
}
private hasContactsHeader(): boolean {
return this.hasTopButton() && Boolean(this.composeContacts.length);
}
}

View File

@ -23,6 +23,8 @@ export abstract class LeftPaneHelper<T> {
_: Readonly<{
i18n: LocalizerType;
showInbox: () => void;
startComposing: () => void;
showChooseGroupMembers: () => void;
}>
): null | ReactChild {
return null;
@ -34,10 +36,28 @@ export abstract class LeftPaneHelper<T> {
getPreRowsNode(
_: Readonly<{
clearGroupCreationError: () => void;
closeCantAddContactToGroupModal: () => unknown;
closeMaximumGroupSizeModal: () => unknown;
closeRecommendedGroupSizeModal: () => unknown;
createGroup: () => unknown;
i18n: LocalizerType;
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown;
setComposeGroupName: (_: string) => unknown;
onChangeComposeSearchTerm: (
event: ChangeEvent<HTMLInputElement>
) => unknown;
removeSelectedContact: (_: string) => unknown;
}>
): null | ReactChild {
return null;
}
getFooterContents(
_: Readonly<{
i18n: LocalizerType;
startSettingGroupMetadata: () => void;
createGroup: () => unknown;
}>
): null | ReactChild {
return null;

View File

@ -0,0 +1,218 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactChild } from 'react';
import { LeftPaneHelper } from './LeftPaneHelper';
import { Row, RowType } from '../ConversationList';
import { PropsDataType as ContactListItemPropsType } from '../conversationList/ContactListItem';
import { LocalizerType } from '../../types/Util';
import { AvatarInput } from '../AvatarInput';
import { Alert } from '../Alert';
import { Spinner } from '../Spinner';
import { Button } from '../Button';
export type LeftPaneSetGroupMetadataPropsType = {
groupAvatar: undefined | ArrayBuffer;
groupName: string;
hasError: boolean;
isCreating: boolean;
selectedContacts: ReadonlyArray<ContactListItemPropsType>;
};
/* eslint-disable class-methods-use-this */
export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<
LeftPaneSetGroupMetadataPropsType
> {
private readonly groupAvatar: undefined | ArrayBuffer;
private readonly groupName: string;
private readonly hasError: boolean;
private readonly isCreating: boolean;
private readonly selectedContacts: ReadonlyArray<ContactListItemPropsType>;
constructor({
groupAvatar,
groupName,
isCreating,
hasError,
selectedContacts,
}: Readonly<LeftPaneSetGroupMetadataPropsType>) {
super();
this.groupAvatar = groupAvatar;
this.groupName = groupName;
this.hasError = hasError;
this.isCreating = isCreating;
this.selectedContacts = selectedContacts;
}
getHeaderContents({
i18n,
showChooseGroupMembers,
}: Readonly<{
i18n: LocalizerType;
showChooseGroupMembers: () => void;
}>): ReactChild {
const backButtonLabel = i18n('setGroupMetadata__back-button');
return (
<div className="module-left-pane__header__contents">
<button
aria-label={backButtonLabel}
className="module-left-pane__header__contents__back-button"
disabled={this.isCreating}
onClick={showChooseGroupMembers}
title={backButtonLabel}
type="button"
/>
<div className="module-left-pane__header__contents__text">
{i18n('setGroupMetadata__title')}
</div>
</div>
);
}
getPreRowsNode({
clearGroupCreationError,
createGroup,
i18n,
setComposeGroupAvatar,
setComposeGroupName,
}: Readonly<{
clearGroupCreationError: () => unknown;
createGroup: () => unknown;
i18n: LocalizerType;
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown;
setComposeGroupName: (_: string) => unknown;
}>): ReactChild {
const disabled = this.isCreating;
return (
<form
className="module-left-pane__header__form"
onSubmit={event => {
event.preventDefault();
event.stopPropagation();
if (!this.canCreateGroup()) {
return;
}
createGroup();
}}
>
<AvatarInput
contextMenuId="left pane group avatar uploader"
disabled={disabled}
i18n={i18n}
onChange={setComposeGroupAvatar}
value={this.groupAvatar}
/>
<input
disabled={disabled}
className="module-left-pane__compose-input"
onChange={event => {
setComposeGroupName(event.target.value);
}}
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
ref={focusRef}
type="text"
value={this.groupName}
/>
{this.hasError && (
<Alert
body={i18n('setGroupMetadata__error-message')}
i18n={i18n}
onClose={clearGroupCreationError}
/>
)}
</form>
);
}
getFooterContents({
createGroup,
i18n,
}: Readonly<{
createGroup: () => unknown;
i18n: LocalizerType;
}>): ReactChild {
return (
<Button disabled={!this.canCreateGroup()} onClick={createGroup}>
{this.isCreating ? (
<Spinner size="20px" svgSize="small" direction="on-avatar" />
) : (
i18n('setGroupMetadata__create-group')
)}
</Button>
);
}
getRowCount(): number {
if (!this.selectedContacts.length) {
return 0;
}
return this.selectedContacts.length + 2;
}
getRow(rowIndex: number): undefined | Row {
if (!this.selectedContacts.length) {
return undefined;
}
if (rowIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'setGroupMetadata__members-header',
};
}
// This puts a blank row for the footer.
if (rowIndex === this.selectedContacts.length + 1) {
return { type: RowType.Blank };
}
const contact = this.selectedContacts[rowIndex - 1];
return contact
? {
type: RowType.Contact,
contact,
isClickable: false,
}
: undefined;
}
// This is deliberately unimplemented because these keyboard shortcuts shouldn't work in
// the composer. The same is true for the "in direction" function below.
getConversationAndMessageAtIndex(
..._args: ReadonlyArray<unknown>
): undefined {
return undefined;
}
getConversationAndMessageInDirection(
..._args: ReadonlyArray<unknown>
): undefined {
return undefined;
}
shouldRecomputeRowHeights(_old: unknown): boolean {
return false;
}
private canCreateGroup(): boolean {
return !this.isCreating && Boolean(this.groupName.trim());
}
}
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}

View File

@ -7,7 +7,6 @@ import {
difference,
flatten,
fromPairs,
isFinite,
isNumber,
values,
} from 'lodash';
@ -18,8 +17,10 @@ import {
GROUP_CREDENTIALS_KEY,
maybeFetchNewCredentials,
} from './services/groupCredentialFetcher';
import { isStorageWriteFeatureEnabled } from './storage/isFeatureEnabled';
import dataInterface from './sql/Client';
import { toWebSafeBase64, fromWebSafeBase64 } from './util/webSafeBase64';
import { assert } from './util/assert';
import {
ConversationAttributesType,
GroupV2MemberType,
@ -72,6 +73,7 @@ import {
import MessageSender, { CallbackResultType } from './textsecure/SendMessage';
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
import { ConversationModel } from './models/conversations';
import { getGroupSizeHardLimit } from './groups/limits';
export { joinViaLink } from './groups/joinViaLink';
@ -222,6 +224,12 @@ type UpdatesResultType = {
newAttributes: ConversationAttributesType;
};
type UploadedAvatarType = {
data: ArrayBuffer;
hash: string;
key: string;
};
// Constants
export const MASTER_KEY_LENGTH = 32;
@ -324,21 +332,25 @@ export function parseGroupLink(
// Group Modifications
async function uploadAvatar({
logId,
path,
publicParams,
secretParams,
}: {
logId: string;
path: string;
publicParams: string;
secretParams: string;
}): Promise<{ hash: string; key: string }> {
async function uploadAvatar(
options: {
logId: string;
publicParams: string;
secretParams: string;
} & ({ path: string } | { data: ArrayBuffer })
): Promise<UploadedAvatarType> {
const { logId, publicParams, secretParams } = options;
try {
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
const data = await window.Signal.Migrations.readAttachmentData(path);
let data: ArrayBuffer;
if ('data' in options) {
({ data } = options);
} else {
data = await window.Signal.Migrations.readAttachmentData(options.path);
}
const hash = await computeHash(data);
const blob = new window.textsecure.protobuf.GroupAttributeBlob();
@ -350,13 +362,14 @@ async function uploadAvatar({
logId: `uploadGroupAvatar/${logId}`,
publicParams,
secretParams,
request: (sender, options) =>
sender.uploadGroupAvatar(ciphertext, options),
request: (sender, requestOptions) =>
sender.uploadGroupAvatar(ciphertext, requestOptions),
});
return {
key,
data,
hash,
key,
};
} catch (error) {
window.log.warn(
@ -367,11 +380,22 @@ async function uploadAvatar({
}
}
async function buildGroupProto({
attributes,
}: {
attributes: ConversationAttributesType;
}): Promise<GroupClass> {
function buildGroupProto(
attributes: Pick<
ConversationAttributesType,
| 'accessControl'
| 'expireTimer'
| 'id'
| 'membersV2'
| 'name'
| 'pendingMembersV2'
| 'publicParams'
| 'revision'
| 'secretParams'
> & {
avatarUrl?: string;
}
): GroupClass {
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
const logId = `groupv2(${attributes.id})`;
@ -404,21 +428,8 @@ async function buildGroupProto({
const titleBlobPlaintext = titleBlob.toArrayBuffer();
proto.title = encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext);
if (attributes.avatar && attributes.avatar.path) {
const { path } = attributes.avatar;
const { key, hash } = await uploadAvatar({
logId,
path,
publicParams,
secretParams,
});
// eslint-disable-next-line no-param-reassign
attributes.avatar.hash = hash;
// eslint-disable-next-line no-param-reassign
attributes.avatar.url = key;
proto.avatar = key;
if (attributes.avatarUrl) {
proto.avatar = attributes.avatarUrl;
}
if (attributes.expireTimer) {
@ -1159,6 +1170,237 @@ export async function fetchMembershipProof({
return response.token;
}
// Creating a group
export async function createGroupV2({
name,
avatar,
conversationIds,
}: Readonly<{
name: string;
avatar: undefined | ArrayBuffer;
conversationIds: Array<string>;
}>): Promise<ConversationModel> {
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials();
if (!isStorageWriteFeatureEnabled()) {
throw new Error(
'createGroupV2: storage service write is not enabled. Cannot create the group'
);
}
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
const masterKeyBuffer = getRandomBytes(32);
const fields = deriveGroupFields(masterKeyBuffer);
const groupId = arrayBufferToBase64(fields.id);
const logId = `groupv2(${groupId})`;
const masterKey = arrayBufferToBase64(masterKeyBuffer);
const secretParams = arrayBufferToBase64(fields.secretParams);
const publicParams = arrayBufferToBase64(fields.publicParams);
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
const ourConversation = window.ConversationController.get(ourConversationId);
if (!ourConversation) {
throw new Error(
`createGroupV2/${logId}: cannot get our own conversation. Cannot create the group`
);
}
const membersV2: Array<GroupV2MemberType> = [
{
conversationId: ourConversationId,
role: MEMBER_ROLE_ENUM.ADMINISTRATOR,
joinedAtVersion: 0,
},
];
const pendingMembersV2: Array<GroupV2PendingMemberType> = [];
let uploadedAvatar: undefined | UploadedAvatarType;
await Promise.all([
...conversationIds.map(async conversationId => {
const contact = window.ConversationController.get(conversationId);
if (!contact) {
assert(
false,
`createGroupV2/${logId}: missing local contact, skipping`
);
return;
}
if (!contact.get('uuid')) {
assert(false, `createGroupV2/${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,
`createGroupV2/${logId}: member is missing GV2 capability; skipping`
);
return;
}
if (contact.get('profileKey') && contact.get('profileKeyCredential')) {
membersV2.push({
conversationId,
role: MEMBER_ROLE_ENUM.DEFAULT,
joinedAtVersion: 0,
});
} else {
pendingMembersV2.push({
addedByUserId: ourConversationId,
conversationId,
timestamp: Date.now(),
role: MEMBER_ROLE_ENUM.DEFAULT,
});
}
}),
(async () => {
if (!avatar) {
return;
}
uploadedAvatar = await uploadAvatar({
data: avatar,
logId,
publicParams,
secretParams,
});
})(),
]);
if (membersV2.length + pendingMembersV2.length > getGroupSizeHardLimit()) {
throw new Error(
`createGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}`
);
}
const protoAndConversationAttributes = {
name,
// Core GroupV2 info
revision: 0,
publicParams,
secretParams,
// GroupV2 state
accessControl: {
attributes: ACCESS_ENUM.MEMBER,
members: ACCESS_ENUM.MEMBER,
addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
},
membersV2,
pendingMembersV2,
};
const groupProto = await buildGroupProto({
id: groupId,
avatarUrl: uploadedAvatar?.key,
...protoAndConversationAttributes,
});
await makeRequestWithTemporalRetry({
logId: `createGroupV2/${logId}`,
publicParams,
secretParams,
request: (sender, options) => sender.createGroup(groupProto, options),
});
let avatarAttribute: ConversationAttributesType['avatar'];
if (uploadedAvatar) {
try {
avatarAttribute = {
url: uploadedAvatar.key,
path: await window.Signal.Migrations.writeNewAttachmentData(
uploadedAvatar.data
),
hash: uploadedAvatar.hash,
};
} catch (err) {
window.log.warn(
`createGroupV2/${logId}: avatar failed to save to disk. Continuing on`
);
}
}
const now = Date.now();
const conversation = await window.ConversationController.getOrCreateAndWait(
groupId,
'group',
{
...protoAndConversationAttributes,
active_at: now,
addedBy: ourConversationId,
avatar: avatarAttribute,
groupVersion: 2,
masterKey,
profileSharing: true,
timestamp: now,
needsStorageServiceSync: true,
}
);
await conversation.queueJob(() => {
window.Signal.Services.storageServiceUploadJob();
});
const timestamp = Date.now();
const profileKey = ourConversation.get('profileKey');
const groupV2Info = conversation.getGroupV2Info({
includePendingMembers: true,
});
await wrapWithSyncMessageSend({
conversation,
logId: `sendMessageToGroup/${logId}`,
send: async sender =>
sender.sendMessageToGroup({
groupV2: groupV2Info,
timestamp,
profileKey: profileKey ? base64ToArrayBuffer(profileKey) : undefined,
}),
timestamp,
});
const createdTheGroupMessage: MessageAttributesType = {
...generateBasicMessage(),
type: 'group-v2-change',
sourceUuid: conversation.ourUuid,
conversationId: conversation.id,
received_at: timestamp,
sent_at: timestamp,
groupV2Change: {
from: ourConversationId,
details: [{ type: 'create' }],
},
};
await window.Signal.Data.saveMessages([createdTheGroupMessage], {
forceSave: true,
});
const model = new window.Whisper.Message(createdTheGroupMessage);
window.MessageController.register(model.id, model);
conversation.trigger('newmessage', model);
return conversation;
}
// Migrating a group
export async function hasV1GroupBeenMigrated(
@ -1451,6 +1693,8 @@ export async function initiateMigrationToGroupV2(
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials();
let ourProfileKey: undefined | string;
try {
await conversation.queueJob(async () => {
const ACCESS_ENUM =
@ -1485,6 +1729,15 @@ export async function initiateMigrationToGroupV2(
`initiateMigrationToGroupV2/${logId}: Couldn't fetch our own conversationId!`
);
}
const ourConversation = window.ConversationController.get(
ourConversationId
);
if (!ourConversation) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: cannot get our own conversation. Cannot migrate`
);
}
ourProfileKey = ourConversation.get('profileKey');
const {
membersV2,
@ -1493,33 +1746,37 @@ export async function initiateMigrationToGroupV2(
previousGroupV1Members,
} = await getGroupMigrationMembers(conversation);
const rawSizeLimit = window.Signal.RemoteConfig.getValue(
'global.groupsv2.groupSizeHardLimit'
);
if (!rawSizeLimit) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: Failed to fetch group size limit`
);
}
const sizeLimit = parseInt(rawSizeLimit, 10);
if (!isFinite(sizeLimit)) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: Failed to parse group size limit`
);
}
if (membersV2.length + pendingMembersV2.length > sizeLimit) {
if (
membersV2.length + pendingMembersV2.length >
getGroupSizeHardLimit()
) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}`
);
}
// Note: A few group elements don't need to change here:
// - avatar
// - name
// - expireTimer
let avatarAttribute: ConversationAttributesType['avatar'];
const avatarPath = conversation.attributes.avatar?.path;
if (avatarPath) {
const { hash, key } = await uploadAvatar({
logId,
publicParams,
secretParams,
path: avatarPath,
});
avatarAttribute = {
url: key,
path: avatarPath,
hash,
};
}
const newAttributes = {
...conversation.attributes,
avatar: avatarAttribute,
// Core GroupV2 info
revision: 0,
@ -1550,12 +1807,10 @@ export async function initiateMigrationToGroupV2(
members: undefined,
};
const groupProto = await buildGroupProto({ attributes: newAttributes });
// Capture the CDK key provided by the server when we uploade
if (groupProto.avatar && newAttributes.avatar) {
newAttributes.avatar.url = groupProto.avatar;
}
const groupProto = buildGroupProto({
...newAttributes,
avatarUrl: avatarAttribute?.url,
});
try {
await makeRequestWithTemporalRetry({
@ -1621,7 +1876,6 @@ export async function initiateMigrationToGroupV2(
// We've migrated the group, now we need to let all other group members know about it
const logId = conversation.idForLogging();
const timestamp = Date.now();
const profileKey = conversation.get('profileKey');
await wrapWithSyncMessageSend({
conversation,
@ -1633,7 +1887,9 @@ export async function initiateMigrationToGroupV2(
includePendingMembers: true,
}),
timestamp,
profileKey: profileKey ? base64ToArrayBuffer(profileKey) : undefined,
profileKey: ourProfileKey
? base64ToArrayBuffer(ourProfileKey)
: undefined,
}),
timestamp,
});

29
ts/groups/limits.ts Normal file
View File

@ -0,0 +1,29 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { getValue, ConfigKeyType } from '../RemoteConfig';
function makeGetter(configKey: ConfigKeyType): (fallback?: number) => number {
return fallback => {
try {
return parseIntOrThrow(
getValue(configKey),
'Failed to parse group size limit'
);
} catch (err) {
if (isNumber(fallback)) {
return fallback;
}
throw err;
}
};
}
export const getGroupSizeRecommendedLimit = makeGetter(
'global.groupsv2.maxGroupSize'
);
export const getGroupSizeHardLimit = makeGetter(
'global.groupsv2.groupSizeHardLimit'
);

View File

@ -1320,6 +1320,9 @@ export class ConversationModel extends window.Backbone.Model<
isBlocked: this.isBlocked(),
isMe: this.isMe(),
isGroupV1AndDisabled: this.isGroupV1AndDisabled(),
isGroupV2Capable: this.isPrivate()
? Boolean(this.get('capabilities')?.gv2)
: undefined,
isPinned: this.get('isPinned'),
isUntrusted: this.isUntrusted(),
isVerified: this.isVerified(),

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { debounce, isNumber, partition } from 'lodash';
@ -19,7 +19,6 @@ import {
StorageManifestClass,
StorageRecordClass,
} from '../textsecure.d';
import { isEnabled } from '../RemoteConfig';
import {
mergeAccountRecord,
mergeContactRecord,
@ -33,6 +32,7 @@ import {
import { ConversationModel } from '../models/conversations';
import { storageJobQueue } from '../util/JobQueue';
import { sleep } from '../util/sleep';
import { isStorageWriteFeatureEnabled } from '../storage/isFeatureEnabled';
const {
eraseStorageServiceStateFromConversations,
@ -882,7 +882,7 @@ async function processManifest(
}
async function sync(): Promise<void> {
if (!isEnabled('desktop.storage')) {
if (!isStorageWriteFeatureEnabled()) {
window.log.info(
'storageService.sync: Not starting desktop.storage is falsey'
);
@ -946,16 +946,9 @@ async function sync(): Promise<void> {
}
async function upload(): Promise<void> {
if (!isEnabled('desktop.storage')) {
if (!isStorageWriteFeatureEnabled()) {
window.log.info(
'storageService.upload: Not starting desktop.storage is falsey'
);
return;
}
if (!isEnabled('desktop.storageWrite2')) {
window.log.info(
'storageService.upload: Not starting desktop.storageWrite2 is falsey'
'storageService.upload: Not starting because the feature is not enabled'
);
return;

View File

@ -16,6 +16,7 @@ import {
} from 'lodash';
import { StateType as RootStateType } from '../reducer';
import * as groups from '../../groups';
import { calling } from '../../services/calling';
import { getOwn } from '../../util/getOwn';
import { assert } from '../../util/assert';
@ -30,6 +31,10 @@ import {
} from '../../components/conversation/conversation-details/PendingInvites';
import { GroupV2Membership } from '../../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { MediaItemType } from '../../components/LightboxGallery';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../groups/limits';
// State
@ -70,6 +75,7 @@ export type ConversationType = {
isArchived?: boolean;
isBlocked?: boolean;
isGroupV1AndDisabled?: boolean;
isGroupV2Capable?: boolean;
isPinned?: boolean;
isUntrusted?: boolean;
isVerified?: boolean;
@ -220,8 +226,47 @@ export type PreJoinConversationType = {
approvalRequired: boolean;
};
export enum ComposerStep {
StartDirectConversation,
ChooseGroupMembers,
SetGroupMetadata,
}
export enum OneTimeModalState {
NeverShown,
Showing,
Shown,
}
type ComposerGroupCreationState = {
groupAvatar: undefined | ArrayBuffer;
groupName: string;
maximumGroupSizeModalState: OneTimeModalState;
recommendedGroupSizeModalState: OneTimeModalState;
selectedConversationIds: Array<string>;
};
type ComposerStateType =
| {
step: ComposerStep.StartDirectConversation;
contactSearchTerm: string;
}
| ({
step: ComposerStep.ChooseGroupMembers;
contactSearchTerm: string;
cantAddContactIdForModal: undefined | string;
} & ComposerGroupCreationState)
| ({
step: ComposerStep.SetGroupMetadata;
} & ComposerGroupCreationState &
(
| { isCreating: false; hasError: boolean }
| { isCreating: true; hasError: false }
));
export type ConversationsStateType = {
preJoinConversation?: PreJoinConversationType;
invitedConversationIdsForNewlyCreatedGroup?: Array<string>;
conversationLookup: ConversationLookupType;
conversationsByE164: ConversationLookupType;
conversationsByUuid: ConversationLookupType;
@ -232,9 +277,7 @@ export type ConversationsStateType = {
selectedConversationTitle?: string;
selectedConversationPanelDepth: number;
showArchived: boolean;
composer?: {
contactSearchTerm: string;
};
composer?: ComposerStateType;
// Note: it's very important that both of these locations are always kept up to date
messagesLookup: MessageLookupType;
@ -268,6 +311,25 @@ export const getConversationCallMode = (
// Actions
type CantAddContactToGroupActionType = {
type: 'CANT_ADD_CONTACT_TO_GROUP';
payload: {
conversationId: string;
};
};
type ClearGroupCreationErrorActionType = { type: 'CLEAR_GROUP_CREATION_ERROR' };
type ClearInvitedConversationsForNewlyCreatedGroupActionType = {
type: 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP';
};
type CloseCantAddContactToGroupModalActionType = {
type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL';
};
type CloseMaximumGroupSizeModalActionType = {
type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL';
};
type CloseRecommendedGroupSizeModalActionType = {
type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL';
};
type SetPreJoinConversationActionType = {
type: 'SET_PRE_JOIN_CONVERSATION';
payload: {
@ -301,6 +363,18 @@ export type ConversationUnloadedActionType = {
id: string;
};
};
type CreateGroupPendingActionType = {
type: 'CREATE_GROUP_PENDING';
};
type CreateGroupFulfilledActionType = {
type: 'CREATE_GROUP_FULFILLED';
payload: {
invitedConversationIds: Array<string>;
};
};
type CreateGroupRejectedActionType = {
type: 'CREATE_GROUP_REJECTED';
};
export type RemoveAllConversationsActionType = {
type: 'CONVERSATIONS_REMOVE_ALL';
payload: null;
@ -435,6 +509,14 @@ export type ShowArchivedConversationsActionType = {
type: 'SHOW_ARCHIVED_CONVERSATIONS';
payload: null;
};
type SetComposeGroupAvatarActionType = {
type: 'SET_COMPOSE_GROUP_AVATAR';
payload: { groupAvatar: undefined | ArrayBuffer };
};
type SetComposeGroupNameActionType = {
type: 'SET_COMPOSE_GROUP_NAME';
payload: { groupName: string };
};
type SetComposeSearchTermActionType = {
type: 'SET_COMPOSE_SEARCH_TERM';
payload: { contactSearchTerm: string };
@ -449,19 +531,42 @@ type SetRecentMediaItemsActionType = {
type StartComposingActionType = {
type: 'START_COMPOSING';
};
type ShowChooseGroupMembersActionType = {
type: 'SHOW_CHOOSE_GROUP_MEMBERS';
};
type StartSettingGroupMetadataActionType = {
type: 'START_SETTING_GROUP_METADATA';
};
export type SwitchToAssociatedViewActionType = {
type: 'SWITCH_TO_ASSOCIATED_VIEW';
payload: { conversationId: string };
};
export type ToggleConversationInChooseMembersActionType = {
type: 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS';
payload: {
conversationId: string;
maxRecommendedGroupSize: number;
maxGroupSize: number;
};
};
export type ConversationActionType =
| CantAddContactToGroupActionType
| ClearChangedMessagesActionType
| ClearGroupCreationErrorActionType
| ClearInvitedConversationsForNewlyCreatedGroupActionType
| ClearSelectedMessageActionType
| ClearUnreadMetricsActionType
| CloseCantAddContactToGroupModalActionType
| CloseMaximumGroupSizeModalActionType
| CloseRecommendedGroupSizeModalActionType
| ConversationAddedActionType
| ConversationChangedActionType
| ConversationRemovedActionType
| ConversationUnloadedActionType
| CreateGroupFulfilledActionType
| CreateGroupPendingActionType
| CreateGroupRejectedActionType
| MessageChangedActionType
| MessageDeletedActionType
| MessagesAddedActionType
@ -473,6 +578,8 @@ export type ConversationActionType =
| RepairOldestMessageActionType
| ScrollToMessageActionType
| SelectedConversationChangedActionType
| SetComposeGroupAvatarActionType
| SetComposeGroupNameActionType
| SetComposeSearchTermActionType
| SetConversationHeaderTitleActionType
| SetIsNearBottomActionType
@ -484,18 +591,28 @@ export type ConversationActionType =
| ShowArchivedConversationsActionType
| ShowInboxActionType
| StartComposingActionType
| SwitchToAssociatedViewActionType;
| ShowChooseGroupMembersActionType
| StartSettingGroupMetadataActionType
| SwitchToAssociatedViewActionType
| ToggleConversationInChooseMembersActionType;
// Action Creators
export const actions = {
cantAddContactToGroup,
clearChangedMessages,
clearInvitedConversationsForNewlyCreatedGroup,
clearGroupCreationError,
clearSelectedMessage,
clearUnreadMetrics,
closeCantAddContactToGroupModal,
closeRecommendedGroupSizeModal,
closeMaximumGroupSizeModal,
conversationAdded,
conversationChanged,
conversationRemoved,
conversationUnloaded,
createGroup,
messageChanged,
messageDeleted,
messagesAdded,
@ -508,6 +625,8 @@ export const actions = {
repairOldestMessage,
scrollToMessage,
selectMessage,
setComposeGroupAvatar,
setComposeGroupName,
setComposeSearchTerm,
setIsNearBottom,
setLoadCountdownStart,
@ -519,9 +638,20 @@ export const actions = {
showArchivedConversations,
showInbox,
startComposing,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
startSettingGroupMetadata,
toggleConversationInChooseMembers,
};
function cantAddContactToGroup(
conversationId: string
): CantAddContactToGroupActionType {
return {
type: 'CANT_ADD_CONTACT_TO_GROUP',
payload: { conversationId },
};
}
function setPreJoinConversation(
data: PreJoinConversationType | undefined
): SetPreJoinConversationActionType {
@ -576,6 +706,52 @@ function conversationUnloaded(id: string): ConversationUnloadedActionType {
},
};
}
function createGroup(): ThunkAction<
void,
RootStateType,
unknown,
| CreateGroupPendingActionType
| CreateGroupFulfilledActionType
| CreateGroupRejectedActionType
| SwitchToAssociatedViewActionType
> {
return async (dispatch, getState, ...args) => {
const { composer } = getState().conversations;
if (
composer?.step !== ComposerStep.SetGroupMetadata ||
composer.isCreating
) {
assert(false, 'Cannot create group in this stage; doing nothing');
return;
}
dispatch({ type: 'CREATE_GROUP_PENDING' });
try {
const conversation = await groups.createGroupV2({
name: composer.groupName,
avatar: composer.groupAvatar,
conversationIds: composer.selectedConversationIds,
});
dispatch({
type: 'CREATE_GROUP_FULFILLED',
payload: {
invitedConversationIds: (
conversation.get('pendingMembersV2') || []
).map(member => member.conversationId),
},
});
openConversationInternal({
conversationId: conversation.id,
switchToAssociatedView: true,
})(dispatch, getState, ...args);
} catch (err) {
dispatch({ type: 'CREATE_GROUP_REJECTED' });
}
};
}
function removeAllConversations(): RemoveAllConversationsActionType {
return {
type: 'CONVERSATIONS_REMOVE_ALL',
@ -761,6 +937,12 @@ function clearChangedMessages(
},
};
}
function clearInvitedConversationsForNewlyCreatedGroup(): ClearInvitedConversationsForNewlyCreatedGroupActionType {
return { type: 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP' };
}
function clearGroupCreationError(): ClearGroupCreationErrorActionType {
return { type: 'CLEAR_GROUP_CREATION_ERROR' };
}
function clearSelectedMessage(): ClearSelectedMessageActionType {
return {
type: 'CLEAR_SELECTED_MESSAGE',
@ -777,7 +959,15 @@ function clearUnreadMetrics(
},
};
}
function closeCantAddContactToGroupModal(): CloseCantAddContactToGroupModalActionType {
return { type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL' };
}
function closeMaximumGroupSizeModal(): CloseMaximumGroupSizeModalActionType {
return { type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL' };
}
function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionType {
return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' };
}
function scrollToMessage(
conversationId: string,
messageId: string
@ -791,6 +981,22 @@ function scrollToMessage(
};
}
function setComposeGroupAvatar(
groupAvatar: undefined | ArrayBuffer
): SetComposeGroupAvatarActionType {
return {
type: 'SET_COMPOSE_GROUP_AVATAR',
payload: { groupAvatar },
};
}
function setComposeGroupName(groupName: string): SetComposeGroupNameActionType {
return {
type: 'SET_COMPOSE_GROUP_NAME',
payload: { groupName },
};
}
function setComposeSearchTerm(
contactSearchTerm: string
): SetComposeSearchTermActionType {
@ -804,6 +1010,10 @@ function startComposing(): StartComposingActionType {
return { type: 'START_COMPOSING' };
}
function showChooseGroupMembers(): ShowChooseGroupMembersActionType {
return { type: 'SHOW_CHOOSE_GROUP_MEMBERS' };
}
function startNewConversationFromPhoneNumber(
e164: string
): ThunkAction<void, RootStateType, unknown, ShowInboxActionType> {
@ -814,6 +1024,37 @@ function startNewConversationFromPhoneNumber(
};
}
function startSettingGroupMetadata(): StartSettingGroupMetadataActionType {
return { type: 'START_SETTING_GROUP_METADATA' };
}
function toggleConversationInChooseMembers(
conversationId: string
): ThunkAction<
void,
RootStateType,
unknown,
ToggleConversationInChooseMembersActionType
> {
return dispatch => {
const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
const maxGroupSize = Math.max(
getGroupSizeHardLimit(1001),
maxRecommendedGroupSize + 1
);
assert(
maxGroupSize > maxRecommendedGroupSize,
'Expected the hard max group size to be larger than the recommended maximum'
);
dispatch({
type: 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS',
payload: { conversationId, maxGroupSize, maxRecommendedGroupSize },
});
};
}
// Note: we need two actions here to simplify. Operations outside of the left pane can
// trigger an 'openConversation' so we go through Whisper.events for all
// conversation selection. Internal just triggers the Whisper.event, and External
@ -1007,10 +1248,94 @@ export function updateConversationLookups(
return result;
}
function closeComposerModal(
state: Readonly<ConversationsStateType>,
modalToClose: 'maximumGroupSizeModalState' | 'recommendedGroupSizeModalState'
): ConversationsStateType {
const { composer } = state;
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
assert(false, "Can't close the modal in this composer step. Doing nothing");
return state;
}
if (composer[modalToClose] !== OneTimeModalState.Showing) {
return state;
}
return {
...state,
composer: {
...composer,
[modalToClose]: OneTimeModalState.Shown,
},
};
}
export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<ConversationActionType>
): ConversationsStateType {
if (action.type === 'CANT_ADD_CONTACT_TO_GROUP') {
const { composer } = state;
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
assert(false, "Can't update modal in this composer step. Doing nothing");
return state;
}
return {
...state,
composer: {
...composer,
cantAddContactIdForModal: action.payload.conversationId,
},
};
}
if (action.type === 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP') {
return omit(state, 'invitedConversationIdsForNewlyCreatedGroup');
}
if (action.type === 'CLEAR_GROUP_CREATION_ERROR') {
const { composer } = state;
if (composer?.step !== ComposerStep.SetGroupMetadata) {
assert(
false,
"Can't clear group creation error in this composer state. Doing nothing"
);
return state;
}
return {
...state,
composer: {
...composer,
hasError: false,
},
};
}
if (action.type === 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL') {
const { composer } = state;
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
assert(
false,
"Can't close the modal in this composer step. Doing nothing"
);
return state;
}
return {
...state,
composer: {
...composer,
cantAddContactIdForModal: undefined,
},
};
}
if (action.type === 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL') {
return closeComposerModal(state, 'maximumGroupSizeModalState' as const);
}
if (action.type === 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL') {
return closeComposerModal(state, 'recommendedGroupSizeModalState' as const);
}
if (action.type === 'SET_PRE_JOIN_CONVERSATION') {
const { payload } = action;
const { data } = payload;
@ -1114,6 +1439,47 @@ export function reducer(
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
return getEmptyState();
}
if (action.type === 'CREATE_GROUP_PENDING') {
const { composer } = state;
if (composer?.step !== ComposerStep.SetGroupMetadata) {
// This should be unlikely, but it can happen if someone closes the composer while
// a group is being created.
return state;
}
return {
...state,
composer: {
...composer,
hasError: false,
isCreating: true,
},
};
}
if (action.type === 'CREATE_GROUP_FULFILLED') {
// We don't do much here and instead rely on `openConversationInternal` to do most of
// the work.
return {
...state,
invitedConversationIdsForNewlyCreatedGroup:
action.payload.invitedConversationIds,
};
}
if (action.type === 'CREATE_GROUP_REJECTED') {
const { composer } = state;
if (composer?.step !== ComposerStep.SetGroupMetadata) {
// This should be unlikely, but it can happen if someone closes the composer while
// a group is being created.
return state;
}
return {
...state,
composer: {
...composer,
hasError: true,
isCreating: false,
},
};
}
if (action.type === 'SET_SELECTED_CONVERSATION_PANEL_DEPTH') {
return {
...state,
@ -1728,7 +2094,7 @@ export function reducer(
}
if (action.type === 'START_COMPOSING') {
if (state.composer) {
if (state.composer?.step === ComposerStep.StartDirectConversation) {
return state;
}
@ -1736,11 +2102,125 @@ export function reducer(
...state,
showArchived: false,
composer: {
step: ComposerStep.StartDirectConversation,
contactSearchTerm: '',
},
};
}
if (action.type === 'SHOW_CHOOSE_GROUP_MEMBERS') {
let selectedConversationIds: Array<string>;
let recommendedGroupSizeModalState: OneTimeModalState;
let maximumGroupSizeModalState: OneTimeModalState;
let groupName: string;
let groupAvatar: undefined | ArrayBuffer;
switch (state.composer?.step) {
case ComposerStep.ChooseGroupMembers:
return state;
case ComposerStep.SetGroupMetadata:
({
selectedConversationIds,
recommendedGroupSizeModalState,
maximumGroupSizeModalState,
groupName,
groupAvatar,
} = state.composer);
break;
default:
selectedConversationIds = [];
recommendedGroupSizeModalState = OneTimeModalState.NeverShown;
maximumGroupSizeModalState = OneTimeModalState.NeverShown;
groupName = '';
break;
}
return {
...state,
showArchived: false,
composer: {
step: ComposerStep.ChooseGroupMembers,
contactSearchTerm: '',
selectedConversationIds,
cantAddContactIdForModal: undefined,
recommendedGroupSizeModalState,
maximumGroupSizeModalState,
groupName,
groupAvatar,
},
};
}
if (action.type === 'START_SETTING_GROUP_METADATA') {
const { composer } = state;
switch (composer?.step) {
case ComposerStep.ChooseGroupMembers:
return {
...state,
showArchived: false,
composer: {
step: ComposerStep.SetGroupMetadata,
isCreating: false,
hasError: false,
...pick(composer, [
'groupAvatar',
'groupName',
'maximumGroupSizeModalState',
'recommendedGroupSizeModalState',
'selectedConversationIds',
]),
},
};
case ComposerStep.SetGroupMetadata:
return state;
default:
assert(
false,
'Cannot transition to setting group metadata from this state'
);
return state;
}
}
if (action.type === 'SET_COMPOSE_GROUP_AVATAR') {
const { composer } = state;
switch (composer?.step) {
case ComposerStep.ChooseGroupMembers:
case ComposerStep.SetGroupMetadata:
return {
...state,
composer: {
...composer,
groupAvatar: action.payload.groupAvatar,
},
};
default:
assert(false, 'Setting compose group avatar at this step is a no-op');
return state;
}
}
if (action.type === 'SET_COMPOSE_GROUP_NAME') {
const { composer } = state;
switch (composer?.step) {
case ComposerStep.ChooseGroupMembers:
case ComposerStep.SetGroupMetadata:
return {
...state,
composer: {
...composer,
groupName: action.payload.groupName,
},
};
default:
assert(false, 'Setting compose group name at this step is a no-op');
return state;
}
}
if (action.type === 'SET_COMPOSE_SEARCH_TERM') {
const { composer } = state;
if (!composer) {
@ -1750,6 +2230,10 @@ export function reducer(
);
return state;
}
if (composer?.step === ComposerStep.SetGroupMetadata) {
assert(false, 'Setting compose search term at this step is a no-op');
return state;
}
return {
...state,
@ -1774,5 +2258,63 @@ export function reducer(
};
}
if (action.type === 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS') {
const { composer } = state;
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
assert(
false,
'Toggling conversation members is a no-op in this composer step'
);
return state;
}
const { selectedConversationIds: oldSelectedConversationIds } = composer;
let {
maximumGroupSizeModalState,
recommendedGroupSizeModalState,
} = composer;
const {
conversationId,
maxGroupSize,
maxRecommendedGroupSize,
} = action.payload;
const selectedConversationIds = without(
oldSelectedConversationIds,
conversationId
);
const shouldAdd =
selectedConversationIds.length === oldSelectedConversationIds.length;
if (shouldAdd) {
// 1 for you, 1 for the new contact.
const newExpectedMemberCount = selectedConversationIds.length + 2;
if (newExpectedMemberCount > maxGroupSize) {
return state;
}
if (
newExpectedMemberCount === maxGroupSize &&
maximumGroupSizeModalState === OneTimeModalState.NeverShown
) {
maximumGroupSizeModalState = OneTimeModalState.Showing;
} else if (
newExpectedMemberCount >= maxRecommendedGroupSize &&
recommendedGroupSizeModalState === OneTimeModalState.NeverShown
) {
recommendedGroupSizeModalState = OneTimeModalState.Showing;
}
selectedConversationIds.push(conversationId);
}
return {
...state,
composer: {
...composer,
maximumGroupSizeModalState,
recommendedGroupSizeModalState,
selectedConversationIds,
},
};
}
return state;
}

View File

@ -8,6 +8,7 @@ import Fuse, { FuseOptions } from 'fuse.js';
import { StateType } from '../reducer';
import {
ComposerStep,
ConversationLookupType,
ConversationMessageType,
ConversationsStateType,
@ -15,10 +16,12 @@ import {
MessageLookupType,
MessagesByConversationType,
MessageType,
OneTimeModalState,
PreJoinConversationType,
} from '../ducks/conversations';
import { LocalizerType } from '../../types/Util';
import { getOwn } from '../../util/getOwn';
import { deconstructLookup } from '../../util/deconstructLookup';
import type { CallsByConversationType } from '../ducks/calling';
import { getCallsByConversation } from './calling';
import { getBubbleProps } from '../../shims/Whisper';
@ -143,9 +146,26 @@ const getComposerState = createSelector(
(state: ConversationsStateType) => state.composer
);
export const isComposing = createSelector(
export const getComposerStep = createSelector(
getComposerState,
(composerState): boolean => Boolean(composerState)
(composerState): undefined | ComposerStep => composerState?.step
);
export const hasGroupCreationError = createSelector(
getComposerState,
(composerState): boolean => {
if (composerState?.step === ComposerStep.SetGroupMetadata) {
return composerState.hasError;
}
return false;
}
);
export const isCreatingGroup = createSelector(
getComposerState,
(composerState): boolean =>
composerState?.step === ComposerStep.SetGroupMetadata &&
composerState.isCreating
);
export const getMessages = createSelector(
@ -273,6 +293,40 @@ export const getLeftPaneLists = createSelector(
_getLeftPaneLists
);
export const getMaximumGroupSizeModalState = createSelector(
getComposerState,
(composerState): OneTimeModalState => {
switch (composerState?.step) {
case ComposerStep.ChooseGroupMembers:
case ComposerStep.SetGroupMetadata:
return composerState.maximumGroupSizeModalState;
default:
assert(
false,
'Can\'t get the maximum group size modal state in this composer state; returning "never shown"'
);
return OneTimeModalState.NeverShown;
}
}
);
export const getRecommendedGroupSizeModalState = createSelector(
getComposerState,
(composerState): OneTimeModalState => {
switch (composerState?.step) {
case ComposerStep.ChooseGroupMembers:
case ComposerStep.SetGroupMetadata:
return composerState.recommendedGroupSizeModalState;
default:
assert(
false,
'Can\'t get the recommended group size modal state in this composer state; returning "never shown"'
);
return OneTimeModalState.NeverShown;
}
}
);
export const getMe = createSelector(
[getConversationLookup, getUserConversationId],
(
@ -290,6 +344,13 @@ export const getComposerContactSearchTerm = createSelector(
assert(false, 'getComposerContactSearchTerm: composer is not open');
return '';
}
if (composer.step === ComposerStep.SetGroupMetadata) {
assert(
false,
'getComposerContactSearchTerm: composer does not have a search term'
);
return '';
}
return composer.contactSearchTerm;
}
);
@ -363,6 +424,102 @@ export const getComposeContacts = createSelector(
}
);
/*
* This returns contacts for the composer when you're picking new group members. It casts
* a wider net than `getContacts`.
*/
const getGroupContacts = createSelector(
getConversationLookup,
(conversationLookup): Array<ConversationType> =>
Object.values(conversationLookup).filter(
contact =>
contact.type === 'direct' &&
!contact.isMe &&
!contact.isBlocked &&
!isConversationUnregistered(contact)
)
);
export const getCandidateGroupContacts = createSelector(
getNormalizedComposerContactSearchTerm,
getGroupContacts,
(searchTerm, contacts): Array<ConversationType> => {
if (searchTerm.length) {
return new Fuse<ConversationType>(
contacts,
COMPOSE_CONTACTS_FUSE_OPTIONS
).search(searchTerm);
}
return contacts.concat().sort((a, b) => collator.compare(a.title, b.title));
}
);
export const getCantAddContactForModal = createSelector(
getConversationLookup,
getComposerState,
(conversationLookup, composerState): undefined | ConversationType => {
if (composerState?.step !== ComposerStep.ChooseGroupMembers) {
return undefined;
}
const conversationId = composerState.cantAddContactIdForModal;
if (!conversationId) {
return undefined;
}
const result = getOwn(conversationLookup, conversationId);
assert(
result,
'getCantAddContactForModal: failed to look up conversation by ID; returning undefined'
);
return result;
}
);
const getGroupCreationComposerState = createSelector(
getComposerState,
(
composerState
): {
groupName: string;
groupAvatar: undefined | ArrayBuffer;
selectedConversationIds: Array<string>;
} => {
switch (composerState?.step) {
case ComposerStep.ChooseGroupMembers:
case ComposerStep.SetGroupMetadata:
return composerState;
default:
assert(
false,
'getSetGroupMetadataComposerState: expected step to be SetGroupMetadata'
);
return {
groupName: '',
groupAvatar: undefined,
selectedConversationIds: [],
};
}
}
);
export const getComposeGroupAvatar = createSelector(
getGroupCreationComposerState,
(composerState): undefined | ArrayBuffer => composerState.groupAvatar
);
export const getComposeGroupName = createSelector(
getGroupCreationComposerState,
(composerState): string => composerState.groupName
);
export const getComposeSelectedContacts = createSelector(
getConversationLookup,
getGroupCreationComposerState,
(conversationLookup, composerState): Array<ConversationType> =>
deconstructLookup(conversationLookup, composerState.selectedConversationIds)
);
// This is where we will put Conversation selector logic, replicating what
// is currently in models/conversation.getProps()
// What needs to happen to pull that selector logic here?
@ -666,3 +823,16 @@ export const getConversationMessagesSelector = createSelector(
};
}
);
export const getInvitedContactsForNewlyCreatedGroup = createSelector(
getConversationLookup,
getConversations,
(
conversationLookup,
{ invitedConversationIdsForNewlyCreatedGroup = [] }
): Array<ConversationType> =>
deconstructLookup(
conversationLookup,
invitedConversationIdsForNewlyCreatedGroup
)
);

View File

@ -10,17 +10,28 @@ import {
PropsType as LeftPanePropsType,
} from '../../components/LeftPane';
import { StateType } from '../reducer';
import { missingCaseError } from '../../util/missingCaseError';
import { ComposerStep, OneTimeModalState } from '../ducks/conversations';
import { getSearchResults, isSearching } from '../selectors/search';
import { getIntl, getRegionCode } from '../selectors/user';
import {
getCandidateGroupContacts,
getCantAddContactForModal,
getComposeContacts,
getComposeGroupAvatar,
getComposeGroupName,
getComposeSelectedContacts,
getComposerContactSearchTerm,
getComposerStep,
getLeftPaneLists,
getMaximumGroupSizeModalState,
getRecommendedGroupSizeModalState,
getSelectedConversationId,
getSelectedMessage,
getShowArchived,
isComposing,
hasGroupCreationError,
isCreatingGroup,
} from '../selectors/conversations';
import { SmartExpiredBuildDialog } from './ExpiredBuildDialog';
@ -61,34 +72,58 @@ function renderUpdateDialog(): JSX.Element {
const getModeSpecificProps = (
state: StateType
): LeftPanePropsType['modeSpecificProps'] => {
if (isComposing(state)) {
return {
mode: LeftPaneMode.Compose,
composeContacts: getComposeContacts(state),
regionCode: getRegionCode(state),
searchTerm: getComposerContactSearchTerm(state),
};
const composerStep = getComposerStep(state);
switch (composerStep) {
case undefined:
if (getShowArchived(state)) {
const { archivedConversations } = getLeftPaneLists(state);
return {
mode: LeftPaneMode.Archive,
archivedConversations,
};
}
if (isSearching(state)) {
return {
mode: LeftPaneMode.Search,
...getSearchResults(state),
};
}
return {
mode: LeftPaneMode.Inbox,
...getLeftPaneLists(state),
};
case ComposerStep.StartDirectConversation:
return {
mode: LeftPaneMode.Compose,
composeContacts: getComposeContacts(state),
regionCode: getRegionCode(state),
searchTerm: getComposerContactSearchTerm(state),
};
case ComposerStep.ChooseGroupMembers:
return {
mode: LeftPaneMode.ChooseGroupMembers,
candidateContacts: getCandidateGroupContacts(state),
cantAddContactForModal: getCantAddContactForModal(state),
isShowingRecommendedGroupSizeModal:
getRecommendedGroupSizeModalState(state) ===
OneTimeModalState.Showing,
isShowingMaximumGroupSizeModal:
getMaximumGroupSizeModalState(state) === OneTimeModalState.Showing,
searchTerm: getComposerContactSearchTerm(state),
selectedContacts: getComposeSelectedContacts(state),
};
case ComposerStep.SetGroupMetadata:
return {
mode: LeftPaneMode.SetGroupMetadata,
groupAvatar: getComposeGroupAvatar(state),
groupName: getComposeGroupName(state),
hasError: hasGroupCreationError(state),
isCreating: isCreatingGroup(state),
selectedContacts: getComposeSelectedContacts(state),
};
default:
throw missingCaseError(composerStep);
}
if (getShowArchived(state)) {
const { archivedConversations } = getLeftPaneLists(state);
return {
mode: LeftPaneMode.Archive,
archivedConversations,
};
}
if (isSearching(state)) {
return {
mode: LeftPaneMode.Search,
...getSearchResults(state),
};
}
return {
mode: LeftPaneMode.Inbox,
...getLeftPaneLists(state),
};
};
const mapStateToProps = (state: StateType) => {

View File

@ -13,6 +13,7 @@ import { getIntl } from '../selectors/user';
import {
getConversationMessagesSelector,
getConversationSelector,
getInvitedContactsForNewlyCreatedGroup,
getSelectedMessage,
} from '../selectors/conversations';
@ -107,6 +108,9 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
'isGroupV1AndDisabled',
]),
...conversationMessages,
invitedContactsForNewlyCreatedGroup: getInvitedContactsForNewlyCreatedGroup(
state
),
selectedMessageId: selectedMessage ? selectedMessage.id : undefined,
i18n: getIntl(state),
renderItem,

View File

@ -0,0 +1,12 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isEnabled } from '../RemoteConfig';
function isStorageFeatureEnabled(): boolean {
return isEnabled('desktop.storage');
}
export function isStorageWriteFeatureEnabled(): boolean {
return isStorageFeatureEnabled() && isEnabled('desktop.storageWrite2');
}

View File

@ -0,0 +1,67 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as sinon from 'sinon';
import * as remoteConfig from '../../RemoteConfig';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../groups/limits';
describe('group limit utilities', () => {
let sinonSandbox: sinon.SinonSandbox;
let getRecommendedLimitStub: sinon.SinonStub;
let getHardLimitStub: sinon.SinonStub;
beforeEach(() => {
sinonSandbox = sinon.createSandbox();
const getValueStub = sinonSandbox.stub(remoteConfig, 'getValue');
getRecommendedLimitStub = getValueStub.withArgs(
'global.groupsv2.maxGroupSize'
);
getHardLimitStub = getValueStub.withArgs(
'global.groupsv2.groupSizeHardLimit'
);
});
afterEach(() => {
sinonSandbox.restore();
});
describe('getGroupSizeRecommendedLimit', () => {
it('throws if the value in remote config is not defined', () => {
getRecommendedLimitStub.returns(undefined);
assert.throws(getGroupSizeRecommendedLimit);
});
it('throws if the value in remote config is not a parseable integer', () => {
getRecommendedLimitStub.returns('uh oh');
assert.throws(getGroupSizeRecommendedLimit);
});
it('returns the value in remote config, parsed as an integer', () => {
getRecommendedLimitStub.returns('123');
assert.strictEqual(getGroupSizeRecommendedLimit(), 123);
});
});
describe('getGroupSizeHardLimit', () => {
it('throws if the value in remote config is not defined', () => {
getHardLimitStub.returns(undefined);
assert.throws(getGroupSizeHardLimit);
});
it('throws if the value in remote config is not a parseable integer', () => {
getHardLimitStub.returns('uh oh');
assert.throws(getGroupSizeHardLimit);
});
it('returns the value in remote config, parsed as an integer', () => {
getHardLimitStub.returns('123');
assert.strictEqual(getGroupSizeHardLimit(), 123);
});
});
});

View File

@ -4,6 +4,8 @@
import { assert } from 'chai';
import {
OneTimeModalState,
ComposerStep,
ConversationLookupType,
ConversationType,
getEmptyState,
@ -11,14 +13,24 @@ import {
import {
_getConversationComparator,
_getLeftPaneLists,
getCandidateGroupContacts,
getCantAddContactForModal,
getComposeContacts,
getComposeGroupAvatar,
getComposeGroupName,
getComposeSelectedContacts,
getComposerContactSearchTerm,
getComposerStep,
getConversationSelector,
getInvitedContactsForNewlyCreatedGroup,
getIsConversationEmptySelector,
getMaximumGroupSizeModalState,
getPlaceholderContact,
getRecommendedGroupSizeModalState,
getSelectedConversation,
getSelectedConversationId,
isComposing,
hasGroupCreationError,
isCreatingGroup,
} from '../../../state/selectors/conversations';
import { noopAction } from '../../../state/ducks/noop';
import { StateType, reducer as rootReducer } from '../../../state/reducer';
@ -219,6 +231,32 @@ describe('both/state/selectors/conversations', () => {
});
});
describe('#getInvitedContactsForNewlyCreatedGroup', () => {
it('returns an empty array if there are no invited contacts', () => {
const state = getEmptyRootState();
assert.deepEqual(getInvitedContactsForNewlyCreatedGroup(state), []);
});
it('returns "hydrated" invited contacts', () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
conversationLookup: {
abc: getDefaultConversation('abc'),
def: getDefaultConversation('def'),
},
invitedConversationIdsForNewlyCreatedGroup: ['def', 'abc'],
},
};
const result = getInvitedContactsForNewlyCreatedGroup(state);
const titles = result.map(conversation => conversation.title);
assert.deepEqual(titles, ['def title', 'abc title']);
});
});
describe('#getIsConversationEmptySelector', () => {
it('returns a selector that returns true for conversations that have no messages', () => {
const state = {
@ -287,24 +325,196 @@ describe('both/state/selectors/conversations', () => {
});
});
describe('#isComposing', () => {
it('returns false if there is no composer state', () => {
assert.isFalse(isComposing(getEmptyRootState()));
describe('#getComposerStep', () => {
it("returns undefined if the composer isn't open", () => {
const state = getEmptyRootState();
const result = getComposerStep(state);
assert.isUndefined(result);
});
it('returns true if there is composer state', () => {
assert.isTrue(
isComposing({
it('returns the first step of the composer', () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.StartDirectConversation as const,
contactSearchTerm: 'foo',
},
},
};
const result = getComposerStep(state);
assert.strictEqual(result, ComposerStep.StartDirectConversation);
});
it('returns the second step of the composer', () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.ChooseGroupMembers as const,
contactSearchTerm: 'foo',
selectedConversationIds: ['abc'],
cantAddContactIdForModal: undefined,
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
groupName: '',
groupAvatar: undefined,
},
},
};
const result = getComposerStep(state);
assert.strictEqual(result, ComposerStep.ChooseGroupMembers);
});
it('returns the third step of the composer', () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: ['abc'],
cantAddContactIdForModal: undefined,
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
groupName: '',
groupAvatar: undefined,
isCreating: false,
hasError: false as const,
},
},
};
const result = getComposerStep(state);
assert.strictEqual(result, ComposerStep.SetGroupMetadata);
});
});
describe('#hasGroupCreationError', () => {
it('returns false if not in the "set group metadata" composer step', () => {
assert.isFalse(hasGroupCreationError(getEmptyRootState()));
assert.isFalse(
hasGroupCreationError({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.StartDirectConversation,
contactSearchTerm: '',
},
},
})
);
});
it('returns false if there is no group creation error', () => {
assert.isFalse(
hasGroupCreationError({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
groupName: '',
groupAvatar: undefined,
isCreating: false as const,
hasError: false as const,
},
},
})
);
});
it('returns true if there is a group creation error', () => {
assert.isTrue(
hasGroupCreationError({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
groupName: '',
groupAvatar: undefined,
isCreating: false as const,
hasError: true as const,
},
},
})
);
});
});
describe('#isCreatingGroup', () => {
it('returns false if not in the "set group metadata" composer step', () => {
assert.isFalse(hasGroupCreationError(getEmptyRootState()));
assert.isFalse(
isCreatingGroup({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.StartDirectConversation,
contactSearchTerm: '',
},
},
})
);
});
it('returns false if the group is not being created', () => {
assert.isFalse(
isCreatingGroup({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
groupName: '',
groupAvatar: undefined,
isCreating: false as const,
hasError: true as const,
},
},
})
);
});
it('returns true if the group is being created', () => {
assert.isTrue(
isCreatingGroup({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
groupName: '',
groupAvatar: undefined,
isCreating: true as const,
hasError: false as const,
},
},
})
);
});
});
describe('#getComposeContacts', () => {
@ -321,6 +531,7 @@ describe('both/state/selectors/conversations', () => {
},
},
composer: {
step: ComposerStep.StartDirectConversation,
contactSearchTerm,
},
},
@ -413,6 +624,154 @@ describe('both/state/selectors/conversations', () => {
});
});
describe('#getCandidateGroupContacts', () => {
const getRootState = (contactSearchTerm = ''): StateType => {
const rootState = getEmptyRootState();
return {
...rootState,
conversations: {
...getEmptyState(),
conversationLookup: {
'our-conversation-id': {
...getDefaultConversation('our-conversation-id'),
isMe: true,
},
'convo-1': {
...getDefaultConversation('convo-1'),
name: 'In System Contacts',
title: 'A. Sorted First',
},
'convo-2': {
...getDefaultConversation('convo-2'),
title: 'B. Sorted Second',
},
'convo-3': {
...getDefaultConversation('convo-3'),
type: 'group',
title: 'Should Be Dropped (group)',
},
'convo-4': {
...getDefaultConversation('convo-4'),
isBlocked: true,
title: 'Should Be Dropped (blocked)',
},
'convo-5': {
...getDefaultConversation('convo-5'),
discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(),
title: 'Should Be Dropped (unregistered)',
},
'convo-6': {
...getDefaultConversation('convo-6'),
title: 'D. Sorted Last',
},
'convo-7': {
...getDefaultConversation('convo-7'),
discoveredUnregisteredAt: Date.now(),
name: 'In System Contacts (and only recently unregistered)',
title: 'C. Sorted Third',
},
},
composer: {
step: ComposerStep.ChooseGroupMembers,
contactSearchTerm,
selectedConversationIds: ['abc'],
cantAddContactIdForModal: undefined,
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
groupName: '',
groupAvatar: undefined,
},
},
user: {
...rootState.user,
ourConversationId: 'our-conversation-id',
i18n,
},
};
};
it('returns sorted contacts when there is no search term', () => {
const state = getRootState();
const result = getCandidateGroupContacts(state);
const ids = result.map(contact => contact.id);
assert.deepEqual(ids, ['convo-1', 'convo-2', 'convo-7', 'convo-6']);
});
it('can search for contacts', () => {
const state = getRootState('system contacts');
const result = getCandidateGroupContacts(state);
const ids = result.map(contact => contact.id);
assert.deepEqual(ids, ['convo-1', 'convo-7']);
});
});
describe('#getCantAddContactForModal', () => {
it('returns undefined if not in the "choose group members" composer step', () => {
assert.isUndefined(getCantAddContactForModal(getEmptyRootState()));
assert.isUndefined(
getCantAddContactForModal({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.StartDirectConversation,
contactSearchTerm: '',
},
},
})
);
});
it("returns undefined if there's no contact marked", () => {
assert.isUndefined(
getCantAddContactForModal({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
cantAddContactIdForModal: undefined,
contactSearchTerm: '',
groupAvatar: undefined,
groupName: '',
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
selectedConversationIds: [],
step: ComposerStep.ChooseGroupMembers as const,
},
},
})
);
});
it('returns the marked contact', () => {
const conversation = getDefaultConversation('abc123');
assert.deepEqual(
getCantAddContactForModal({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
conversationLookup: { abc123: conversation },
composer: {
cantAddContactIdForModal: 'abc123',
contactSearchTerm: '',
groupAvatar: undefined,
groupName: '',
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
selectedConversationIds: [],
step: ComposerStep.ChooseGroupMembers as const,
},
},
}),
conversation
);
});
});
describe('#getComposerContactSearchTerm', () => {
it("returns the composer's contact search term", () => {
assert.strictEqual(
@ -421,6 +780,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.StartDirectConversation,
contactSearchTerm: 'foo bar',
},
},
@ -668,6 +1028,163 @@ describe('both/state/selectors/conversations', () => {
});
});
describe('#getMaximumGroupSizeModalState', () => {
it('returns the modal state', () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
cantAddContactIdForModal: undefined,
contactSearchTerm: 'to be cleared',
groupAvatar: undefined,
groupName: '',
maximumGroupSizeModalState: OneTimeModalState.Showing,
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
selectedConversationIds: [],
step: ComposerStep.ChooseGroupMembers as const,
},
},
};
assert.strictEqual(
getMaximumGroupSizeModalState(state),
OneTimeModalState.Showing
);
});
});
describe('#getRecommendedGroupSizeModalState', () => {
it('returns the modal state', () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
cantAddContactIdForModal: undefined,
contactSearchTerm: 'to be cleared',
groupAvatar: undefined,
groupName: '',
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
recommendedGroupSizeModalState: OneTimeModalState.Showing,
selectedConversationIds: [],
step: ComposerStep.ChooseGroupMembers as const,
},
},
};
assert.strictEqual(
getRecommendedGroupSizeModalState(state),
OneTimeModalState.Showing
);
});
});
describe('#getComposeGroupAvatar', () => {
it('returns undefined if there is no group avatar', () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: ['abc'],
cantAddContactIdForModal: undefined,
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
groupName: '',
groupAvatar: undefined,
isCreating: false,
hasError: false as const,
},
},
};
assert.isUndefined(getComposeGroupAvatar(state));
});
it('returns the group avatar', () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: ['abc'],
cantAddContactIdForModal: undefined,
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
groupName: '',
groupAvatar: new Uint8Array([1, 2, 3]).buffer,
isCreating: false,
hasError: false as const,
},
},
};
assert.deepEqual(
getComposeGroupAvatar(state),
new Uint8Array([1, 2, 3]).buffer
);
});
});
describe('#getComposeGroupName', () => {
it('returns the group name', () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
composer: {
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: ['abc'],
cantAddContactIdForModal: undefined,
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
groupName: 'foo bar',
groupAvatar: undefined,
isCreating: false,
hasError: false as const,
},
},
};
assert.deepEqual(getComposeGroupName(state), 'foo bar');
});
});
describe('#getComposeSelectedContacts', () => {
it("returns the composer's selected contacts", () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
conversationLookup: {
'convo-1': {
...getDefaultConversation('convo-1'),
title: 'Person One',
},
'convo-2': {
...getDefaultConversation('convo-2'),
title: 'Person Two',
},
},
composer: {
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: ['convo-2', 'convo-1'],
cantAddContactIdForModal: undefined,
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
maximumGroupSizeModalState: OneTimeModalState.NeverShown,
groupName: 'foo bar',
groupAvatar: undefined,
isCreating: false,
hasError: false as const,
},
},
};
const titles = getComposeSelectedContacts(state).map(
contact => contact.title
);
assert.deepEqual(titles, ['Person Two', 'Person One']);
});
});
describe('#getSelectedConversationId', () => {
it('returns undefined if no conversation is selected', () => {
const state = {

View File

@ -0,0 +1,71 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { parseIntOrThrow } from '../../util/parseIntOrThrow';
describe('parseIntOrThrow', () => {
describe('when passed a number argument', () => {
it('returns the number when passed an integer', () => {
assert.strictEqual(parseIntOrThrow(0, "shouldn't happen"), 0);
assert.strictEqual(parseIntOrThrow(123, "shouldn't happen"), 123);
assert.strictEqual(parseIntOrThrow(-123, "shouldn't happen"), -123);
});
it('throws when passed a decimal value', () => {
assert.throws(() => parseIntOrThrow(0.2, 'uh oh'), 'uh oh');
assert.throws(() => parseIntOrThrow(1.23, 'uh oh'), 'uh oh');
});
it('throws when passed NaN', () => {
assert.throws(() => parseIntOrThrow(NaN, 'uh oh'), 'uh oh');
});
it('throws when passed ∞', () => {
assert.throws(() => parseIntOrThrow(Infinity, 'uh oh'), 'uh oh');
assert.throws(() => parseIntOrThrow(-Infinity, 'uh oh'), 'uh oh');
});
});
describe('when passed a string argument', () => {
it('returns the number when passed an integer', () => {
assert.strictEqual(parseIntOrThrow('0', "shouldn't happen"), 0);
assert.strictEqual(parseIntOrThrow('123', "shouldn't happen"), 123);
assert.strictEqual(parseIntOrThrow('-123', "shouldn't happen"), -123);
});
it('parses decimal values like parseInt', () => {
assert.strictEqual(parseIntOrThrow('0.2', "shouldn't happen"), 0);
assert.strictEqual(parseIntOrThrow('12.34', "shouldn't happen"), 12);
assert.strictEqual(parseIntOrThrow('-12.34', "shouldn't happen"), -12);
});
it('parses values in base 10', () => {
assert.strictEqual(parseIntOrThrow('0x12', "shouldn't happen"), 0);
});
it('throws when passed non-parseable strings', () => {
assert.throws(() => parseIntOrThrow('', 'uh oh'), 'uh oh');
assert.throws(() => parseIntOrThrow('uh 123', 'uh oh'), 'uh oh');
assert.throws(() => parseIntOrThrow('uh oh', 'uh oh'), 'uh oh');
});
});
describe('when passed other arguments', () => {
it("throws when passed arguments that aren't strings or numbers", () => {
assert.throws(() => parseIntOrThrow(null, 'uh oh'), 'uh oh');
assert.throws(() => parseIntOrThrow(undefined, 'uh oh'), 'uh oh');
assert.throws(() => parseIntOrThrow(['123'], 'uh oh'), 'uh oh');
});
it('throws when passed a stringifiable argument, unlike parseInt', () => {
const obj = {
toString() {
return '123';
},
};
assert.throws(() => parseIntOrThrow(obj, 'uh oh'), 'uh oh');
});
});
});

View File

@ -0,0 +1,71 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { parseIntWithFallback } from '../../util/parseIntWithFallback';
describe('parseIntWithFallback', () => {
describe('when passed a number argument', () => {
it('returns the number when passed an integer', () => {
assert.strictEqual(parseIntWithFallback(0, -1), 0);
assert.strictEqual(parseIntWithFallback(123, -1), 123);
assert.strictEqual(parseIntWithFallback(-123, -1), -123);
});
it('returns the fallback when passed a decimal value', () => {
assert.strictEqual(parseIntWithFallback(0.2, -1), -1);
assert.strictEqual(parseIntWithFallback(1.23, -1), -1);
});
it('returns the fallback when passed NaN', () => {
assert.strictEqual(parseIntWithFallback(NaN, -1), -1);
});
it('returns the fallback when passed ∞', () => {
assert.strictEqual(parseIntWithFallback(Infinity, -1), -1);
assert.strictEqual(parseIntWithFallback(-Infinity, -1), -1);
});
});
describe('when passed a string argument', () => {
it('returns the number when passed an integer', () => {
assert.strictEqual(parseIntWithFallback('0', -1), 0);
assert.strictEqual(parseIntWithFallback('123', -1), 123);
assert.strictEqual(parseIntWithFallback('-123', -1), -123);
});
it('parses decimal values like parseInt', () => {
assert.strictEqual(parseIntWithFallback('0.2', -1), 0);
assert.strictEqual(parseIntWithFallback('12.34', -1), 12);
assert.strictEqual(parseIntWithFallback('-12.34', -1), -12);
});
it('parses values in base 10', () => {
assert.strictEqual(parseIntWithFallback('0x12', -1), 0);
});
it('returns the fallback when passed non-parseable strings', () => {
assert.strictEqual(parseIntWithFallback('', -1), -1);
assert.strictEqual(parseIntWithFallback('uh 123', -1), -1);
assert.strictEqual(parseIntWithFallback('uh oh', -1), -1);
});
});
describe('when passed other arguments', () => {
it("returns the fallback when passed arguments that aren't strings or numbers", () => {
assert.strictEqual(parseIntWithFallback(null, -1), -1);
assert.strictEqual(parseIntWithFallback(undefined, -1), -1);
assert.strictEqual(parseIntWithFallback(['123'], -1), -1);
});
it('returns the fallback when passed a stringifiable argument, unlike parseInt', () => {
const obj = {
toString() {
return '123';
},
};
assert.strictEqual(parseIntWithFallback(obj, -1), -1);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,196 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as sinon from 'sinon';
import { times } from 'lodash';
import { v4 as uuid } from 'uuid';
import { RowType } from '../../../components/ConversationList';
import * as remoteConfig from '../../../RemoteConfig';
import { ContactCheckboxDisabledReason } from '../../../components/conversationList/ContactCheckbox';
import { LeftPaneChooseGroupMembersHelper } from '../../../components/leftPane/LeftPaneChooseGroupMembersHelper';
describe('LeftPaneChooseGroupMembersHelper', () => {
const defaults = {
candidateContacts: [],
cantAddContactForModal: undefined,
isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false,
searchTerm: '',
selectedContacts: [],
};
const fakeContact = () => ({
id: uuid(),
isGroupV2Capable: true,
title: uuid(),
type: 'direct' as const,
});
let sinonSandbox: sinon.SinonSandbox;
beforeEach(() => {
sinonSandbox = sinon.createSandbox();
sinonSandbox
.stub(remoteConfig, 'getValue')
.withArgs('global.groupsv2.maxGroupSize')
.returns('22')
.withArgs('global.groupsv2.groupSizeHardLimit')
.returns('33');
});
afterEach(() => {
sinonSandbox.restore();
});
describe('getRowCount', () => {
it('returns 0 if there are no contacts', () => {
assert.strictEqual(
new LeftPaneChooseGroupMembersHelper({
...defaults,
candidateContacts: [],
searchTerm: '',
selectedContacts: [fakeContact()],
}).getRowCount(),
0
);
assert.strictEqual(
new LeftPaneChooseGroupMembersHelper({
...defaults,
candidateContacts: [],
searchTerm: 'foo bar',
selectedContacts: [fakeContact()],
}).getRowCount(),
0
);
});
it('returns the number of candidate contacts + 2 if there are any', () => {
assert.strictEqual(
new LeftPaneChooseGroupMembersHelper({
...defaults,
candidateContacts: [fakeContact(), fakeContact()],
searchTerm: '',
selectedContacts: [fakeContact()],
}).getRowCount(),
4
);
});
});
describe('getRow', () => {
it('returns undefined if there are no contacts', () => {
assert.isUndefined(
new LeftPaneChooseGroupMembersHelper({
...defaults,
candidateContacts: [],
searchTerm: '',
selectedContacts: [fakeContact()],
}).getRow(0)
);
assert.isUndefined(
new LeftPaneChooseGroupMembersHelper({
...defaults,
candidateContacts: [],
searchTerm: '',
selectedContacts: [fakeContact()],
}).getRow(99)
);
assert.isUndefined(
new LeftPaneChooseGroupMembersHelper({
...defaults,
candidateContacts: [],
searchTerm: 'foo bar',
selectedContacts: [fakeContact()],
}).getRow(0)
);
});
it('returns a header, then the contacts, then a blank space if there are contacts', () => {
const candidateContacts = [fakeContact(), fakeContact()];
const helper = new LeftPaneChooseGroupMembersHelper({
...defaults,
candidateContacts,
searchTerm: 'foo bar',
selectedContacts: [candidateContacts[1]],
});
assert.deepEqual(helper.getRow(0), {
type: RowType.Header,
i18nKey: 'contactsHeader',
});
assert.deepEqual(helper.getRow(1), {
type: RowType.ContactCheckbox,
contact: candidateContacts[0],
isChecked: false,
disabledReason: undefined,
});
assert.deepEqual(helper.getRow(2), {
type: RowType.ContactCheckbox,
contact: candidateContacts[1],
isChecked: true,
disabledReason: undefined,
});
assert.deepEqual(helper.getRow(3), { type: RowType.Blank });
});
it("disables non-selected contact checkboxes if you've selected the maximum number of contacts", () => {
const candidateContacts = times(50, () => fakeContact());
const helper = new LeftPaneChooseGroupMembersHelper({
...defaults,
candidateContacts,
searchTerm: 'foo bar',
selectedContacts: candidateContacts.slice(1, 33),
});
assert.deepEqual(helper.getRow(1), {
type: RowType.ContactCheckbox,
contact: candidateContacts[0],
isChecked: false,
disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected,
});
assert.deepEqual(helper.getRow(2), {
type: RowType.ContactCheckbox,
contact: candidateContacts[1],
isChecked: true,
disabledReason: undefined,
});
});
it("disables contacts that aren't GV2-capable, unless they are already selected somehow", () => {
const candidateContacts = [
{ ...fakeContact(), isGroupV2Capable: false },
{ ...fakeContact(), isGroupV2Capable: undefined },
{ ...fakeContact(), isGroupV2Capable: false },
];
const helper = new LeftPaneChooseGroupMembersHelper({
...defaults,
candidateContacts,
searchTerm: 'foo bar',
selectedContacts: [candidateContacts[2]],
});
assert.deepEqual(helper.getRow(1), {
type: RowType.ContactCheckbox,
contact: candidateContacts[0],
isChecked: false,
disabledReason: ContactCheckboxDisabledReason.NotCapable,
});
assert.deepEqual(helper.getRow(2), {
type: RowType.ContactCheckbox,
contact: candidateContacts[1],
isChecked: false,
disabledReason: ContactCheckboxDisabledReason.NotCapable,
});
assert.deepEqual(helper.getRow(3), {
type: RowType.ContactCheckbox,
contact: candidateContacts[2],
isChecked: true,
disabledReason: undefined,
});
});
});
});

View File

@ -2,9 +2,11 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as sinon from 'sinon';
import { v4 as uuid } from 'uuid';
import { RowType } from '../../../components/ConversationList';
import { FindDirection } from '../../../components/leftPane/LeftPaneHelper';
import * as remoteConfig from '../../../RemoteConfig';
import { LeftPaneComposeHelper } from '../../../components/leftPane/LeftPaneComposeHelper';
@ -15,8 +17,48 @@ describe('LeftPaneComposeHelper', () => {
type: 'direct' as const,
});
let sinonSandbox: sinon.SinonSandbox;
let remoteConfigStub: sinon.SinonStub;
beforeEach(() => {
sinonSandbox = sinon.createSandbox();
remoteConfigStub = sinonSandbox
.stub(remoteConfig, 'isEnabled')
.withArgs('desktop.storage')
.returns(true)
.withArgs('desktop.storageWrite2')
.returns(true);
});
afterEach(() => {
sinonSandbox.restore();
});
describe('getRowCount', () => {
it('returns the number of contacts if not searching for a phone number', () => {
it('returns 1 (for the "new group" button) if not searching and there are no contacts', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [],
regionCode: 'US',
searchTerm: '',
}).getRowCount(),
1
);
});
it('returns the number of contacts + 2 (for the "new group" button and header) if not searching', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
searchTerm: '',
}).getRowCount(),
4
);
});
it('returns the number of contacts if searching, but not for a phone number', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [],
@ -29,26 +71,50 @@ describe('LeftPaneComposeHelper', () => {
new LeftPaneComposeHelper({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
searchTerm: '',
searchTerm: 'foo bar',
}).getRowCount(),
2
);
});
it('returns the number of contacts + 1 if searching for a phone number', () => {
it('returns 1 (for the "Start new conversation" button) if searching for a phone number with no contacts', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [],
regionCode: 'US',
searchTerm: '+16505551234',
}).getRowCount(),
1
);
});
it('returns the number of contacts + 2 (for the "Start new conversation" button and header) if searching for a phone number', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
searchTerm: '+16505551234',
}).getRowCount(),
3
4
);
});
});
describe('getRow', () => {
it('returns each contact as a row if not searching for a phone number', () => {
it('returns a "new group" button if not searching and there are no contacts', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [],
regionCode: 'US',
searchTerm: '',
});
assert.deepEqual(helper.getRow(0), {
type: RowType.CreateNewGroup,
});
assert.isUndefined(helper.getRow(1));
});
it('returns a "new group" button, a header, and contacts if not searching', () => {
const composeContacts = [fakeContact(), fakeContact()];
const helper = new LeftPaneComposeHelper({
composeContacts,
@ -56,6 +122,72 @@ describe('LeftPaneComposeHelper', () => {
searchTerm: '',
});
assert.deepEqual(helper.getRow(0), {
type: RowType.CreateNewGroup,
});
assert.deepEqual(helper.getRow(1), {
type: RowType.Header,
i18nKey: 'contactsHeader',
});
assert.deepEqual(helper.getRow(2), {
type: RowType.Contact,
contact: composeContacts[0],
});
assert.deepEqual(helper.getRow(3), {
type: RowType.Contact,
contact: composeContacts[1],
});
});
it("doesn't let you create new groups if storage service write is disabled", () => {
remoteConfigStub
.withArgs('desktop.storage')
.returns(false)
.withArgs('desktop.storageWrite2')
.returns(false);
assert.isUndefined(
new LeftPaneComposeHelper({
composeContacts: [],
regionCode: 'US',
searchTerm: '',
}).getRow(0)
);
remoteConfigStub
.withArgs('desktop.storage')
.returns(true)
.withArgs('desktop.storageWrite2')
.returns(false);
assert.isUndefined(
new LeftPaneComposeHelper({
composeContacts: [],
regionCode: 'US',
searchTerm: '',
}).getRow(0)
);
});
it('returns no rows if searching and there are no results', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [],
regionCode: 'US',
searchTerm: 'foo bar',
});
assert.isUndefined(helper.getRow(0));
assert.isUndefined(helper.getRow(1));
});
it('returns one row per contact if searching', () => {
const composeContacts = [fakeContact(), fakeContact()];
const helper = new LeftPaneComposeHelper({
composeContacts,
regionCode: 'US',
searchTerm: 'foo bar',
});
assert.deepEqual(helper.getRow(0), {
type: RowType.Contact,
contact: composeContacts[0],
@ -66,7 +198,21 @@ describe('LeftPaneComposeHelper', () => {
});
});
it('returns a "start new conversation" row if searching for a phone number', () => {
it('returns a "start new conversation" row if searching for a phone number and there are no results', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [],
regionCode: 'US',
searchTerm: '+16505551234',
});
assert.deepEqual(helper.getRow(0), {
type: RowType.StartNewConversation,
phoneNumber: '+16505551234',
});
assert.isUndefined(helper.getRow(1));
});
it('returns a "start new conversation" row, a header, and contacts if searching for a phone number', () => {
const composeContacts = [fakeContact(), fakeContact()];
const helper = new LeftPaneComposeHelper({
composeContacts,
@ -79,10 +225,14 @@ describe('LeftPaneComposeHelper', () => {
phoneNumber: '+16505551234',
});
assert.deepEqual(helper.getRow(1), {
type: RowType.Header,
i18nKey: 'contactsHeader',
});
assert.deepEqual(helper.getRow(2), {
type: RowType.Contact,
contact: composeContacts[0],
});
assert.deepEqual(helper.getRow(2), {
assert.deepEqual(helper.getRow(3), {
type: RowType.Contact,
contact: composeContacts[1],
});
@ -120,7 +270,7 @@ describe('LeftPaneComposeHelper', () => {
});
describe('shouldRecomputeRowHeights', () => {
it('always returns false because row heights are constant', () => {
it('returns false if going from "no header" to "no header"', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
@ -130,15 +280,79 @@ describe('LeftPaneComposeHelper', () => {
assert.isFalse(
helper.shouldRecomputeRowHeights({
composeContacts: [fakeContact()],
regionCode: 'US',
searchTerm: 'foo bar',
})
);
assert.isFalse(
helper.shouldRecomputeRowHeights({
composeContacts: [fakeContact(), fakeContact(), fakeContact()],
regionCode: 'US',
searchTerm: 'bing bong',
})
);
});
it('returns false if going from "has header" to "has header"', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
searchTerm: '',
});
assert.isFalse(
helper.shouldRecomputeRowHeights({
composeContacts: [fakeContact()],
regionCode: 'US',
searchTerm: '',
})
);
assert.isFalse(
helper.shouldRecomputeRowHeights({
composeContacts: [fakeContact()],
regionCode: 'US',
searchTerm: '+16505559876',
})
);
});
it('returns true if going from "no header" to "has header"', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
searchTerm: 'foo bar',
});
assert.isTrue(
helper.shouldRecomputeRowHeights({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
searchTerm: '',
})
);
assert.isTrue(
helper.shouldRecomputeRowHeights({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
searchTerm: '+16505551234',
})
);
});
it('returns true if going from "has header" to "no header"', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
searchTerm: '',
});
assert.isTrue(
helper.shouldRecomputeRowHeights({
composeContacts: [fakeContact(), fakeContact()],
regionCode: 'US',
searchTerm: 'foo bar',
})
);
});
});
});

View File

@ -0,0 +1,85 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { v4 as uuid } from 'uuid';
import { RowType } from '../../../components/ConversationList';
import { LeftPaneSetGroupMetadataHelper } from '../../../components/leftPane/LeftPaneSetGroupMetadataHelper';
describe('LeftPaneSetGroupMetadataHelper', () => {
const fakeContact = () => ({
id: uuid(),
title: uuid(),
type: 'direct' as const,
});
describe('getRowCount', () => {
it('returns 0 if there are no contacts', () => {
assert.strictEqual(
new LeftPaneSetGroupMetadataHelper({
groupAvatar: undefined,
groupName: '',
hasError: false,
isCreating: false,
selectedContacts: [],
}).getRowCount(),
0
);
});
it('returns the number of candidate contacts + 2 if there are any', () => {
assert.strictEqual(
new LeftPaneSetGroupMetadataHelper({
groupAvatar: undefined,
groupName: '',
hasError: false,
isCreating: false,
selectedContacts: [fakeContact(), fakeContact()],
}).getRowCount(),
4
);
});
});
describe('getRow', () => {
it('returns undefined if there are no contacts', () => {
assert.isUndefined(
new LeftPaneSetGroupMetadataHelper({
groupAvatar: undefined,
groupName: '',
hasError: false,
isCreating: false,
selectedContacts: [],
}).getRow(0)
);
});
it('returns a header, then the contacts, then a blank space if there are contacts', () => {
const selectedContacts = [fakeContact(), fakeContact()];
const helper = new LeftPaneSetGroupMetadataHelper({
groupAvatar: undefined,
groupName: '',
hasError: false,
isCreating: false,
selectedContacts,
});
assert.deepEqual(helper.getRow(0), {
type: RowType.Header,
i18nKey: 'setGroupMetadata__members-header',
});
assert.deepEqual(helper.getRow(1), {
type: RowType.Contact,
contact: selectedContacts[0],
isClickable: false,
});
assert.deepEqual(helper.getRow(2), {
type: RowType.Contact,
contact: selectedContacts[1],
isClickable: false,
});
assert.deepEqual(helper.getRow(3), { type: RowType.Blank });
});
});
});

View File

@ -14461,6 +14461,24 @@
"updated": "2021-01-06T00:47:54.313Z",
"reasonDetail": "Needed to render remote video elements. Doesn't interact with the DOM."
},
{
"rule": "React-useRef",
"path": "ts/components/AvatarInput.js",
"line": " const fileInputRef = react_1.useRef(null);",
"lineNumber": 40,
"reasonCategory": "usageTrusted",
"updated": "2021-03-01T18:34:36.638Z",
"reasonDetail": "Needed to trigger a click on a 'file' input. Doesn't update the DOM."
},
{
"rule": "React-useRef",
"path": "ts/components/AvatarInput.js",
"line": " const menuTriggerRef = react_1.useRef(null);",
"lineNumber": 43,
"reasonCategory": "usageTrusted",
"updated": "2021-03-01T18:34:36.638Z",
"reasonDetail": "Used to reference popup menu"
},
{
"rule": "React-useRef",
"path": "ts/components/AvatarPopup.js",
@ -14641,11 +14659,29 @@
"updated": "2020-10-26T23:56:13.482Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/ContactPills.js",
"line": " const elRef = react_1.useRef(null);",
"lineNumber": 27,
"reasonCategory": "usageTrusted",
"updated": "2021-03-01T18:34:36.638Z",
"reasonDetail": "Used for scrolling. Doesn't otherwise manipulate the DOM"
},
{
"rule": "React-useRef",
"path": "ts/components/ContactPills.js",
"line": " const previousChildCountRef = react_1.useRef(childCount);",
"lineNumber": 29,
"reasonCategory": "usageTrusted",
"updated": "2021-03-01T18:34:36.638Z",
"reasonDetail": "Doesn't reference the DOM. Refers to a number"
},
{
"rule": "React-useRef",
"path": "ts/components/ConversationList.js",
"line": " const listRef = react_1.useRef(null);",
"lineNumber": 44,
"lineNumber": 49,
"reasonCategory": "usageTrusted",
"updated": "2021-02-12T16:25:08.285Z",
"reasonDetail": "Used for scroll calculations"
@ -14706,7 +14742,7 @@
"rule": "React-useRef",
"path": "ts/components/LeftPane.js",
"line": " const previousModeSpecificPropsRef = react_1.useRef(modeSpecificProps);",
"lineNumber": 47,
"lineNumber": 52,
"reasonCategory": "usageTrusted",
"updated": "2021-02-12T16:25:08.285Z",
"reasonDetail": "Doesn't interact with the DOM."
@ -14715,7 +14751,7 @@
"rule": "React-useRef",
"path": "ts/components/LeftPane.tsx",
"line": " const previousModeSpecificPropsRef = useRef(modeSpecificProps);",
"lineNumber": 104,
"lineNumber": 143,
"reasonCategory": "usageTrusted",
"updated": "2021-02-12T16:25:08.285Z",
"reasonDetail": "Doesn't interact with the DOM."
@ -14969,7 +15005,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Timeline.js",
"line": " this.listRef = react_1.default.createRef();",
"lineNumber": 31,
"lineNumber": 32,
"reasonCategory": "usageTrusted",
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Timeline needs to interact with its child List directly"

View File

@ -0,0 +1,24 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function parseIntOrThrow(value: unknown, message: string): number {
let result: number;
switch (typeof value) {
case 'number':
result = value;
break;
case 'string':
result = parseInt(value, 10);
break;
default:
result = NaN;
break;
}
if (!Number.isInteger(result)) {
throw new Error(message);
}
return result;
}

View File

@ -0,0 +1,12 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { parseIntOrThrow } from './parseIntOrThrow';
export function parseIntWithFallback(value: unknown, fallback: number): number {
try {
return parseIntOrThrow(value, 'Failed to parse');
} catch (err) {
return fallback;
}
}