diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e5b9432d7..7783dfb36 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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": "Can’t 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$” can’t be added to the group because they’re using an old version of Signal. You can add them to the group after they’ve updated Signal.", + "description": "Shown in the alert when you try to add someone who can't be added to a group", + "placeholders": { + "max": { + "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 couldn’t 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 aren’t 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$ can’t 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 can’t 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": "They’ve been invited to join, and won’t 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" } } diff --git a/images/icons/v2/camera-outline-24.svg b/images/icons/v2/camera-outline-24.svg new file mode 100644 index 000000000..329aaa8e0 --- /dev/null +++ b/images/icons/v2/camera-outline-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index e9098d8c3..b3d14b8bd 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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 { diff --git a/stylesheets/components/Alert.scss b/stylesheets/components/Alert.scss new file mode 100644 index 000000000..ca7437bab --- /dev/null +++ b/stylesheets/components/Alert.scss @@ -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; + } +} diff --git a/stylesheets/components/AvatarInput.scss b/stylesheets/components/AvatarInput.scss new file mode 100644 index 000000000..df781fcf0 --- /dev/null +++ b/stylesheets/components/AvatarInput.scss @@ -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; + } + } + } +} diff --git a/stylesheets/components/ContactPill.scss b/stylesheets/components/ContactPill.scss new file mode 100644 index 000000000..796ab2561 --- /dev/null +++ b/stylesheets/components/ContactPill.scss @@ -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); + } + } + } + } +} diff --git a/stylesheets/components/ContactPills.scss b/stylesheets/components/ContactPills.scss new file mode 100644 index 000000000..c0ccc04b4 --- /dev/null +++ b/stylesheets/components/ContactPills.scss @@ -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 + } +} diff --git a/stylesheets/components/GroupDialog.scss b/stylesheets/components/GroupDialog.scss new file mode 100644 index 000000000..5ec114a9e --- /dev/null +++ b/stylesheets/components/GroupDialog.scss @@ -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; + } + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 674be45d1..5d54d61ac 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index a3132c6d3..1fc29b3be 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -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' diff --git a/ts/components/Alert.tsx b/ts/components/Alert.tsx new file mode 100644 index 000000000..4c3c45731 --- /dev/null +++ b/ts/components/Alert.tsx @@ -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 = ({ + body, + i18n, + onClose, + title, +}) => ( + +
+ {title &&

{title}

} +

{body}

+
+ +
+
+
+); diff --git a/ts/components/AvatarInput.stories.tsx b/ts/components/AvatarInput.stories.tsx new file mode 100644 index 000000000..0087dc871 --- /dev/null +++ b/ts/components/AvatarInput.stories.tsx @@ -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(startValue); + const [objectUrl, setObjectUrl] = useState(); + + useEffect(() => { + if (!value) { + setObjectUrl(undefined); + return noop; + } + const url = URL.createObjectURL(new Blob([value])); + setObjectUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [value]); + + return ( + <> +
+ +
+
+
Processed image (if it exists)
+ {objectUrl && } +
+ + ); +}; + +story.add('No start state', () => { + return ; +}); + +story.add('Starting with a value', () => { + return ; +}); diff --git a/ts/components/AvatarInput.tsx b/ts/components/AvatarInput.tsx new file mode 100644 index 000000000..3de139979 --- /dev/null +++ b/ts/components/AvatarInput.tsx @@ -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 = ({ + contextMenuId, + disabled, + i18n, + onChange, + value, +}) => { + const fileInputRef = useRef(null); + // Comes from a third-party dependency + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuTriggerRef = useRef(null); + + const [objectUrl, setObjectUrl] = useState(); + 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 + ); + 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 = value + ? event => { + const menuTrigger = menuTriggerRef.current; + if (!menuTrigger) { + return; + } + menuTrigger.handleContextClick(event); + } + : startUpload; + + const onInputChange: ChangeEventHandler = 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 ( + <> + + + + + + {i18n('AvatarInput--upload-photo-choice')} + + + {i18n('AvatarInput--remove-photo-choice')} + + + + + ); +}; + +async function processFile(file: File): Promise { + 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 { + 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'); + }); +} diff --git a/ts/components/ContactPill.tsx b/ts/components/ContactPill.tsx new file mode 100644 index 000000000..beb53d959 --- /dev/null +++ b/ts/components/ContactPill.tsx @@ -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 = ({ + avatarPath, + color, + firstName, + i18n, + id, + name, + phoneNumber, + profileName, + title, + onClickRemove, +}) => { + const removeLabel = i18n('ContactPill--remove'); + + return ( +
+ + +
+ ); +}; diff --git a/ts/components/ContactPills.stories.tsx b/ts/components/ContactPills.stories.tsx new file mode 100644 index 000000000..c486ef8f0 --- /dev/null +++ b/ts/components/ContactPills.stories.tsx @@ -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; + +const contacts: Array = 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', () => ); + +story.add('One contact', () => ( + + + +)); + +story.add('Three contacts', () => ( + + + + + +)); + +story.add('Four contacts, one with a long name', () => ( + + + + + + +)); + +story.add('Fifty contacts', () => ( + + {contacts.map(contact => ( + + ))} + +)); diff --git a/ts/components/ContactPills.tsx b/ts/components/ContactPills.tsx new file mode 100644 index 000000000..eda64626e --- /dev/null +++ b/ts/components/ContactPills.tsx @@ -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 = ({ children }) => { + const elRef = useRef(null); + + const childCount = Children.count(children); + const previousChildCountRef = useRef(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 ( +
+ {children} +
+ ); +}; diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx index 41de5daed..6f6d99ec4 100644 --- a/ts/components/ConversationList.stories.tsx +++ b/ts/components/ConversationList.stories.tsx @@ -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 = [ 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): PropsType => ({ @@ -52,6 +62,7 @@ const createProps = (rows: ReadonlyArray): PropsType => ({ i18n, onSelectConversation: action('onSelectConversation'), onClickArchiveButton: action('onClickArchiveButton'), + onClickContactCheckbox: action('onClickContactCheckbox'), renderMessageSearchResult: (id: string, style: React.CSSProperties) => ( ): PropsType => ({ to={defaultConversations[1]} /> ), + showChooseGroupMembers: action('showChooseGroupMembers'), startNewConversationFromPhoneNumber: action( 'startNewConversationFromPhoneNumber' ), @@ -144,6 +156,56 @@ story.add('Contact: group', () => ( /> )); +story.add('Contact checkboxes', () => ( + +)); + +story.add('Contact checkboxes: disabled', () => ( + +)); + { const createConversation = ( overrideProps: Partial = {} diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index 147257ef8..103186443 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -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 = ({ getRow, i18n, onClickArchiveButton, + onClickContactCheckbox, onSelectConversation, renderMessageSearchResult, rowCount, scrollToRowIndex, shouldRecomputeRowHeights, + showChooseGroupMembers, startNewConversationFromPhoneNumber, }) => { const listRef = useRef(null); @@ -148,13 +180,29 @@ export const ConversationList: React.FC = ({ ); - case RowType.Contact: + case RowType.Blank: + return
; + case RowType.Contact: { + const { isClickable = true } = row; return ( + ); + } + case RowType.ContactCheckbox: + return ( + ); @@ -168,6 +216,15 @@ export const ConversationList: React.FC = ({ i18n={i18n} /> ); + case RowType.CreateNewGroup: + return ( + + ); case RowType.Header: return (
= ({ getRow, i18n, onClickArchiveButton, + onClickContactCheckbox, onSelectConversation, renderMessageSearchResult, + showChooseGroupMembers, startNewConversationFromPhoneNumber, ] ); diff --git a/ts/components/GroupDialog.tsx b/ts/components/GroupDialog.tsx new file mode 100644 index 000000000..f31e92ade --- /dev/null +++ b/ts/components/GroupDialog.tsx @@ -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): JSX.Element { + const { + children, + i18n, + onClickPrimaryButton, + onClose, + primaryButtonText, + title, + } = props; + + let secondaryButton: undefined | ReactChild; + if ('secondaryButtonText' in props) { + const { onClickSecondaryButton, secondaryButtonText } = props; + secondaryButton = ( + + ); + } + + return ( + +
+ +
+
+ + ); +} + +type ParagraphPropsType = { + children: ReactNode; +}; + +GroupDialog.Paragraph = ({ + children, +}: Readonly): JSX.Element => ( +

{children}

+); + +type ContactsPropsType = { + contacts: Array; + i18n: LocalizerType; +}; + +GroupDialog.Contacts = ({ contacts, i18n }: Readonly) => ( +
    + {contacts.map(contact => ( +
  • + + +
  • + ))} +
+); + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} diff --git a/ts/components/GroupV1MigrationDialog.tsx b/ts/components/GroupV1MigrationDialog.tsx index 6fd3aed1d..68f43336f 100644 --- a/ts/components/GroupV1MigrationDialog.tsx +++ b/ts/components/GroupV1MigrationDialog.tsx @@ -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 = 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 ( -
- -
+ ); } - - return ( -
- - -
- ); -} +); function renderMembers( members: Array, 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 ( -
-
-
-
{i18n(key)}
- {sortByTitle(members).map(member => ( -
- {' '} - - {member.title} - -
- ))} -
-
+ <> + {i18n(key)} + + ); } diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index 6c5a60996..6ffb10678 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -77,6 +77,12 @@ const defaultModeSpecificProps = { const emptySearchResultsGroup = { isLoading: false, results: [] }; const createProps = (overrideProps: Partial = {}): 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 => ({ 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, }); diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 5c3a319f3..b23b4b27c 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -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 = ({ + cantAddContactToGroup, + clearGroupCreationError, + closeCantAddContactToGroupModal, + closeMaximumGroupSizeModal, + closeRecommendedGroupSizeModal, + createGroup, i18n, modeSpecificProps, openConversationInternal, @@ -96,10 +130,15 @@ export const LeftPane: React.FC = ({ 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 = ({ 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 = ({ ]); 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 = ({ return (
- {helper.getHeaderContents({ i18n, showInbox }) || renderMainHeader()} + {helper.getHeaderContents({ + i18n, + showInbox, + startComposing, + showChooseGroupMembers, + }) || renderMainHeader()}
{renderExpiredBuildDialog()} {renderRelinkDialog()} @@ -288,6 +372,24 @@ export const LeftPane: React.FC = ({ 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 = ({ selectedConversationId )} shouldRecomputeRowHeights={shouldRecomputeRowHeights} + showChooseGroupMembers={showChooseGroupMembers} startNewConversationFromPhoneNumber={ startNewConversationFromPhoneNumber } @@ -313,6 +416,9 @@ export const LeftPane: React.FC = ({
)} + {footerContents && ( +
{footerContents}
+ )}
); }; diff --git a/ts/components/NewlyCreatedGroupInvitedContactsDialog.stories.tsx b/ts/components/NewlyCreatedGroupInvitedContactsDialog.stories.tsx new file mode 100644 index 000000000..69815f824 --- /dev/null +++ b/ts/components/NewlyCreatedGroupInvitedContactsDialog.stories.tsx @@ -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 = [ + { + 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', () => ( + +)); + +story.add('Two contacts', () => ( + +)); diff --git a/ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx b/ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx new file mode 100644 index 000000000..1a9800ae0 --- /dev/null +++ b/ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx @@ -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; + i18n: LocalizerType; + onClose: () => void; +}; + +export const NewlyCreatedGroupInvitedContactsDialog: FunctionComponent = ({ + contacts, + i18n, + onClose, +}) => { + let title: string; + let body: ReactNode; + if (contacts.length === 1) { + const contact = contacts[0]; + + title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--one'); + body = ( + <> + + ]} + /> + + + {i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')} + + + ); + } else { + title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--many', [ + contacts.length.toString(), + ]); + body = ( + <> + + {i18n( + 'NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--many' + )} + + + {i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')} + + + + ); + } + + return ( + { + window.location.href = + 'https://support.signal.org/hc/articles/360007319331-Group-chats'; + }} + onClose={onClose} + title={title} + > + {body} + + ); +}; diff --git a/ts/components/conversation/ContactName.tsx b/ts/components/conversation/ContactName.tsx index 5ae4b80c0..e4b132769 100644 --- a/ts/components/conversation/ContactName.tsx +++ b/ts/components/conversation/ContactName.tsx @@ -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 ( - + ); }; diff --git a/ts/components/conversation/GroupV1Migration.tsx b/ts/components/conversation/GroupV1Migration.tsx index 3bd068253..08c0c4ddd 100644 --- a/ts/components/conversation/GroupV1Migration.tsx +++ b/ts/components/conversation/GroupV1Migration.tsx @@ -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')} {showingDialog ? ( - - - window.log.warn('GroupV1Migration: Modal called migrate()') - } - onClose={dismissDialog} - /> - + + window.log.warn('GroupV1Migration: Modal called migrate()') + } + onClose={dismissDialog} + /> ) : null}
); diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index f0eb3fa82..9f6344bc0 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -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 = { 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 => ({ oldestUnreadIndex: number('oldestUnreadIndex', overrideProps.oldestUnreadIndex || 0) || undefined, + invitedContactsForNewlyCreatedGroup: + overrideProps.invitedContactsForNewlyCreatedGroup || [], id: '', renderItem, @@ -361,3 +366,22 @@ story.add('Without Oldest Message', () => { return ; }); + +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 ; +}); diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 19ed5de09..88d87f5e9 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -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; 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 { }; 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 { } return ( -
- - {({ height, width }) => { - if (this.mostRecentWidth && this.mostRecentWidth !== width) { - this.resizeFlag = true; + <> +
+ + {({ 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 ( - - ); - }} - - {shouldShowScrollDownButton ? ( - + ); + }} + + {shouldShowScrollDownButton ? ( + + ) : null} +
+ + {Boolean(invitedContactsForNewlyCreatedGroup.length) && ( + - ) : null} -
+ )} + ); } } diff --git a/ts/components/conversationList/BaseConversationListItem.tsx b/ts/components/conversationList/BaseConversationListItem.tsx index 7307eb8f2..6f65f1e3a 100644 --- a/ts/components/conversationList/BaseConversationListItem.tsx +++ b/ts/components/conversationList/BaseConversationListItem.tsx @@ -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 = React.memo( ({ avatarPath, + checked, color, conversationType, + disabled, headerDate, headerName, i18n, @@ -74,17 +79,32 @@ export const BaseConversationListItem: FunctionComponent = React.memo ? isNoteToSelf : Boolean(isMe); - return ( - + {checkboxNode} + + ); + + const commonClassNames = classNames(BASE_CLASS_NAME, { + [`${BASE_CLASS_NAME}--has-unread`]: isUnread, + [`${BASE_CLASS_NAME}--is-selected`]: isSelected, + }); + + if (isCheckbox) { + return ( + + ); + } + + if (onClick) { + return ( + + ); + } + + return ( +
+ {contents} +
); } ); diff --git a/ts/components/conversationList/ContactCheckbox.tsx b/ts/components/conversationList/ContactCheckbox.tsx new file mode 100644 index 000000000..e28ea7bfc --- /dev/null +++ b/ts/components/conversationList/ContactCheckbox.tsx @@ -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 = React.memo( + ({ + about, + avatarPath, + color, + disabledReason, + i18n, + id, + isChecked, + name, + onClick, + phoneNumber, + profileName, + style, + title, + }) => { + const disabled = Boolean(disabledReason); + + const headerName = ( + + ); + + const messageText = about ? : null; + + const onClickItem = () => { + onClick(id, disabledReason); + }; + + return ( + + ); + } +); diff --git a/ts/components/conversationList/ContactListItem.tsx b/ts/components/conversationList/ContactListItem.tsx index 7632ad6f6..5f031a3c9 100644 --- a/ts/components/conversationList/ContactListItem.tsx +++ b/ts/components/conversationList/ContactListItem.tsx @@ -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 = React.memo( const messageText = about && !isMe ? : null; - const onClickItem = useCallback(() => onClick(id), [onClick, id]); - return ( = React.memo( isSelected={false} messageText={messageText} name={name} - onClick={onClickItem} + onClick={onClick ? () => onClick(id) : undefined} phoneNumber={phoneNumber} profileName={profileName} style={style} diff --git a/ts/components/conversationList/CreateNewGroupButton.tsx b/ts/components/conversationList/CreateNewGroupButton.tsx new file mode 100644 index 000000000..865e5858f --- /dev/null +++ b/ts/components/conversationList/CreateNewGroupButton.tsx @@ -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 = React.memo( + ({ i18n, onClick, style }) => { + const title = i18n('createNewGroupButton'); + + return ( + + ); + } +); diff --git a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx new file mode 100644 index 000000000..43e8dadef --- /dev/null +++ b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx @@ -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; + cantAddContactForModal: undefined | ConversationType; + isShowingRecommendedGroupSizeModal: boolean; + isShowingMaximumGroupSizeModal: boolean; + searchTerm: string; + selectedContacts: Array; +}; + +/* eslint-disable class-methods-use-this */ + +export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper< + LeftPaneChooseGroupMembersPropsType +> { + private readonly candidateContacts: ReadonlyArray; + + private readonly cantAddContactForModal: + | undefined + | Readonly<{ title: string }>; + + private readonly isShowingMaximumGroupSizeModal: boolean; + + private readonly isShowingRecommendedGroupSizeModal: boolean; + + private readonly searchTerm: string; + + private readonly selectedContacts: Array; + + private readonly selectedConversationIdsSet: Set; + + constructor({ + candidateContacts, + cantAddContactForModal, + isShowingMaximumGroupSizeModal, + isShowingRecommendedGroupSizeModal, + searchTerm, + selectedContacts, + }: Readonly) { + 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 ( +
+
+ ); + } + + getPreRowsNode({ + closeCantAddContactToGroupModal, + closeMaximumGroupSizeModal, + closeRecommendedGroupSizeModal, + i18n, + onChangeComposeSearchTerm, + removeSelectedContact, + }: Readonly<{ + closeCantAddContactToGroupModal: () => unknown; + closeMaximumGroupSizeModal: () => unknown; + closeRecommendedGroupSizeModal: () => unknown; + i18n: LocalizerType; + onChangeComposeSearchTerm: ( + event: ChangeEvent + ) => 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 ( + <> +
+ +
+ + {Boolean(this.selectedContacts.length) && ( + + {this.selectedContacts.map(contact => ( + + ))} + + )} + + {this.getRowCount() ? null : ( +
+ {i18n('newConversationNoContacts')} +
+ )} + + {modalDetails && ( + + )} + + ); + } + + getFooterContents({ + i18n, + startSettingGroupMetadata, + }: Readonly<{ + i18n: LocalizerType; + startSettingGroupMetadata: () => void; + }>): ReactChild { + return ( + + ); + } + + 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 + ): undefined { + return undefined; + } + + getConversationAndMessageInDirection( + ..._args: ReadonlyArray + ): 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(); + } +} diff --git a/ts/components/leftPane/LeftPaneComposeHelper.tsx b/ts/components/leftPane/LeftPaneComposeHelper.tsx index 5800c8101..f232d65e0 100644 --- a/ts/components/leftPane/LeftPaneComposeHelper.tsx +++ b/ts/components/leftPane/LeftPaneComposeHelper.tsx @@ -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; @@ -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): 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); } } diff --git a/ts/components/leftPane/LeftPaneHelper.tsx b/ts/components/leftPane/LeftPaneHelper.tsx index b51e6ade9..fa438abc6 100644 --- a/ts/components/leftPane/LeftPaneHelper.tsx +++ b/ts/components/leftPane/LeftPaneHelper.tsx @@ -23,6 +23,8 @@ export abstract class LeftPaneHelper { _: Readonly<{ i18n: LocalizerType; showInbox: () => void; + startComposing: () => void; + showChooseGroupMembers: () => void; }> ): null | ReactChild { return null; @@ -34,10 +36,28 @@ export abstract class LeftPaneHelper { getPreRowsNode( _: Readonly<{ + clearGroupCreationError: () => void; + closeCantAddContactToGroupModal: () => unknown; + closeMaximumGroupSizeModal: () => unknown; + closeRecommendedGroupSizeModal: () => unknown; + createGroup: () => unknown; i18n: LocalizerType; + setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown; + setComposeGroupName: (_: string) => unknown; onChangeComposeSearchTerm: ( event: ChangeEvent ) => unknown; + removeSelectedContact: (_: string) => unknown; + }> + ): null | ReactChild { + return null; + } + + getFooterContents( + _: Readonly<{ + i18n: LocalizerType; + startSettingGroupMetadata: () => void; + createGroup: () => unknown; }> ): null | ReactChild { return null; diff --git a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx new file mode 100644 index 000000000..9b3740e50 --- /dev/null +++ b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx @@ -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; +}; + +/* 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; + + constructor({ + groupAvatar, + groupName, + isCreating, + hasError, + selectedContacts, + }: Readonly) { + 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 ( +
+
+ ); + } + + 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 ( +
{ + event.preventDefault(); + event.stopPropagation(); + + if (!this.canCreateGroup()) { + return; + } + + createGroup(); + }} + > + + { + setComposeGroupName(event.target.value); + }} + placeholder={i18n('setGroupMetadata__group-name-placeholder')} + ref={focusRef} + type="text" + value={this.groupName} + /> + + {this.hasError && ( + + )} + + ); + } + + getFooterContents({ + createGroup, + i18n, + }: Readonly<{ + createGroup: () => unknown; + i18n: LocalizerType; + }>): ReactChild { + return ( + + ); + } + + 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 + ): undefined { + return undefined; + } + + getConversationAndMessageInDirection( + ..._args: ReadonlyArray + ): 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(); + } +} diff --git a/ts/groups.ts b/ts/groups.ts index c7e7d8313..568cc56b5 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -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 { + 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 { +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; +}>): Promise { + // 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 = [ + { + conversationId: ourConversationId, + role: MEMBER_ROLE_ENUM.ADMINISTRATOR, + joinedAtVersion: 0, + }, + ]; + const pendingMembersV2: Array = []; + + 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, }); diff --git a/ts/groups/limits.ts b/ts/groups/limits.ts new file mode 100644 index 000000000..9cce18fce --- /dev/null +++ b/ts/groups/limits.ts @@ -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' +); diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 14037650c..4fd817e53 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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(), diff --git a/ts/services/storage.ts b/ts/services/storage.ts index aef9b3919..053aab740 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -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 { - 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 { } async function upload(): Promise { - 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; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 1777ea356..b6767fa7a 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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; +}; + +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; 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; + }; +}; +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 { @@ -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, + 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 = getEmptyState(), action: Readonly ): 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; + 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; } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index df6971687..22db86a24 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -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 => + Object.values(conversationLookup).filter( + contact => + contact.type === 'direct' && + !contact.isMe && + !contact.isBlocked && + !isConversationUnregistered(contact) + ) +); + +export const getCandidateGroupContacts = createSelector( + getNormalizedComposerContactSearchTerm, + getGroupContacts, + (searchTerm, contacts): Array => { + if (searchTerm.length) { + return new Fuse( + contacts, + COMPOSE_CONTACTS_FUSE_OPTIONS + ).search(searchTerm); + } + return contacts.concat().sort((a, b) => collator.compare(a.title, b.title)); + } +); + +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; + } => { + 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 => + 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 => + deconstructLookup( + conversationLookup, + invitedConversationIdsForNewlyCreatedGroup + ) +); diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 7f0ffb502..28e513a3f 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -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) => { diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 6f0559e7b..f1d52833a 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -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, diff --git a/ts/storage/isFeatureEnabled.ts b/ts/storage/isFeatureEnabled.ts new file mode 100644 index 000000000..7cc7b1293 --- /dev/null +++ b/ts/storage/isFeatureEnabled.ts @@ -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'); +} diff --git a/ts/test-both/groups/limits_test.ts b/ts/test-both/groups/limits_test.ts new file mode 100644 index 000000000..77a79e704 --- /dev/null +++ b/ts/test-both/groups/limits_test.ts @@ -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); + }); + }); +}); diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 4238107c6..7afa0bcce 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -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 = { diff --git a/ts/test-both/util/parseIntOrThrow_test.ts b/ts/test-both/util/parseIntOrThrow_test.ts new file mode 100644 index 000000000..8e2abf4c8 --- /dev/null +++ b/ts/test-both/util/parseIntOrThrow_test.ts @@ -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'); + }); + }); +}); diff --git a/ts/test-both/util/parseIntWithFallback_test.ts b/ts/test-both/util/parseIntWithFallback_test.ts new file mode 100644 index 000000000..f5e68f911 --- /dev/null +++ b/ts/test-both/util/parseIntWithFallback_test.ts @@ -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); + }); + }); +}); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 56e163253..e94f93b00 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -3,42 +3,65 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; +import { v4 as uuid } from 'uuid'; +import { times } from 'lodash'; import { set } from 'lodash/fp'; import { reducer as rootReducer } from '../../../state/reducer'; import { noopAction } from '../../../state/ducks/noop'; import { actions, + OneTimeModalState, + ComposerStep, ConversationMessageType, - ConversationsStateType, ConversationType, + ConversationsStateType, + MessageType, + SwitchToAssociatedViewActionType, + ToggleConversationInChooseMembersActionType, getConversationCallMode, getEmptyState, - MessageType, reducer, updateConversationLookups, - SwitchToAssociatedViewActionType, } from '../../../state/ducks/conversations'; import { CallMode } from '../../../types/Calling'; +import * as groups from '../../../groups'; const { + cantAddContactToGroup, + clearGroupCreationError, + clearInvitedConversationsForNewlyCreatedGroup, + closeCantAddContactToGroupModal, + closeMaximumGroupSizeModal, + closeRecommendedGroupSizeModal, + createGroup, messageSizeChanged, openConversationInternal, repairNewestMessage, repairOldestMessage, + setComposeGroupAvatar, + setComposeGroupName, setComposeSearchTerm, setPreJoinConversation, showArchivedConversations, showInbox, startComposing, + showChooseGroupMembers, + startSettingGroupMetadata, + toggleConversationInChooseMembers, } = actions; describe('both/state/ducks/conversations', () => { const getEmptyRootState = () => rootReducer(undefined, noopAction()); let sinonSandbox: sinon.SinonSandbox; + let createGroupStub: sinon.SinonStub; beforeEach(() => { sinonSandbox = sinon.createSandbox(); + + sinonSandbox.stub(window.Whisper.events, 'trigger'); + + createGroupStub = sinonSandbox.stub(groups, 'createGroupV2'); }); afterEach(() => { @@ -317,10 +340,6 @@ describe('both/state/ducks/conversations', () => { } describe('openConversationInternal', () => { - beforeEach(() => { - sinonSandbox.stub(window.Whisper.events, 'trigger'); - }); - it("returns a thunk that triggers a 'showConversation' event when passed a conversation ID", () => { const dispatch = sinon.spy(); @@ -442,6 +461,390 @@ describe('both/state/ducks/conversations', () => { }); }); + describe('CANT_ADD_CONTACT_TO_GROUP', () => { + it('marks the conversation ID as "cannot add"', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: undefined, + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = cantAddContactToGroup('abc123'); + const result = reducer(state, action); + + assert( + result.composer?.step === ComposerStep.ChooseGroupMembers && + result.composer.cantAddContactIdForModal === 'abc123' + ); + }); + }); + + describe('CLEAR_GROUP_CREATION_ERROR', () => { + it('clears the group creation error', () => { + const state = { + ...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, + }, + }; + const action = clearGroupCreationError(); + const result = reducer(state, action); + + assert( + result.composer?.step === ComposerStep.SetGroupMetadata && + result.composer.hasError === false + ); + }); + }); + + describe('CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP', () => { + it('clears the list of invited conversation IDs', () => { + const state = { + ...getEmptyState(), + invitedConversationIdsForNewlyCreatedGroup: ['abc123', 'def456'], + }; + const action = clearInvitedConversationsForNewlyCreatedGroup(); + const result = reducer(state, action); + + assert.isUndefined(result.invitedConversationIdsForNewlyCreatedGroup); + }); + }); + + describe('CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL', () => { + it('closes the "cannot add contact" modal"', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = closeCantAddContactToGroupModal(); + const result = reducer(state, action); + + assert( + result.composer?.step === ComposerStep.ChooseGroupMembers && + result.composer.cantAddContactIdForModal === undefined, + 'Expected the contact ID to be cleared' + ); + }); + }); + + describe('CLOSE_MAXIMUM_GROUP_SIZE_MODAL', () => { + it('closes the maximum group size modal if it was open', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.Showing, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = closeMaximumGroupSizeModal(); + const result = reducer(state, action); + + assert( + result.composer?.step === ComposerStep.ChooseGroupMembers && + result.composer.maximumGroupSizeModalState === + OneTimeModalState.Shown, + 'Expected the modal to be closed' + ); + }); + + it('does nothing if the maximum group size modal was never shown', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = closeMaximumGroupSizeModal(); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it('does nothing if the maximum group size modal already closed', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.Shown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = closeMaximumGroupSizeModal(); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + }); + + describe('CLOSE_RECOMMENDED_GROUP_SIZE_MODAL', () => { + it('closes the recommended group size modal if it was open', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.Showing, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = closeRecommendedGroupSizeModal(); + const result = reducer(state, action); + + assert( + result.composer?.step === ComposerStep.ChooseGroupMembers && + result.composer.recommendedGroupSizeModalState === + OneTimeModalState.Shown, + 'Expected the modal to be closed' + ); + }); + + it('does nothing if the recommended group size modal was never shown', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = closeRecommendedGroupSizeModal(); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it('does nothing if the recommended group size modal already closed', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = closeRecommendedGroupSizeModal(); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + }); + + describe('createGroup', () => { + const conversationsState = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: ['abc123'], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([1, 2, 3]).buffer, + isCreating: false as const, + hasError: true as const, + }, + }; + + it('immediately dispatches a CREATE_GROUP_PENDING action, which puts the composer in a loading state', () => { + const dispatch = sinon.spy(); + + createGroup()( + dispatch, + () => ({ + ...getEmptyRootState(), + conversations: conversationsState, + }), + null + ); + + sinon.assert.calledOnce(dispatch); + sinon.assert.calledWith(dispatch, { type: 'CREATE_GROUP_PENDING' }); + + const action = dispatch.getCall(0).args[0]; + + const result = reducer(conversationsState, action); + + assert( + result.composer?.step === ComposerStep.SetGroupMetadata && + result.composer.isCreating && + !result.composer.hasError + ); + }); + + it('calls groups.createGroupV2', async () => { + await createGroup()( + sinon.spy(), + () => ({ + ...getEmptyRootState(), + conversations: conversationsState, + }), + null + ); + + sinon.assert.calledOnce(createGroupStub); + sinon.assert.calledWith(createGroupStub, { + name: 'Foo Bar Group', + avatar: new Uint8Array([1, 2, 3]).buffer, + conversationIds: ['abc123'], + }); + }); + + it('dispatches a CREATE_GROUP_REJECTED action if group creation fails, which marks the state with an error', async () => { + createGroupStub.rejects(new Error('uh oh')); + + const dispatch = sinon.spy(); + + const createGroupPromise = createGroup()( + dispatch, + () => ({ + ...getEmptyRootState(), + conversations: conversationsState, + }), + null + ); + + const pendingAction = dispatch.getCall(0).args[0]; + const stateAfterPending = reducer(conversationsState, pendingAction); + + await createGroupPromise; + + sinon.assert.calledTwice(dispatch); + sinon.assert.calledWith(dispatch, { type: 'CREATE_GROUP_REJECTED' }); + + const rejectedAction = dispatch.getCall(1).args[0]; + const result = reducer(stateAfterPending, rejectedAction); + + assert( + result.composer?.step === ComposerStep.SetGroupMetadata && + !result.composer.isCreating && + result.composer.hasError + ); + }); + + it("when rejecting, does nothing to the left pane if it's no longer in this composer state", async () => { + createGroupStub.rejects(new Error('uh oh')); + + const dispatch = sinon.spy(); + + const createGroupPromise = createGroup()( + dispatch, + () => ({ + ...getEmptyRootState(), + conversations: conversationsState, + }), + null + ); + + await createGroupPromise; + + const state = getEmptyState(); + const rejectedAction = dispatch.getCall(1).args[0]; + const result = reducer(state, rejectedAction); + + assert.strictEqual(result, state); + }); + + it('dispatches a CREATE_GROUP_FULFILLED event (which updates the newly-created conversation IDs), triggers a showConversation event and switches to the associated conversation on success', async () => { + createGroupStub.resolves({ + id: '9876', + get: (key: string) => { + if (key !== 'pendingMembersV2') { + throw new Error('This getter is not set up for this test'); + } + return [{ conversationId: 'xyz999' }]; + }, + }); + + const dispatch = sinon.spy(); + + await createGroup()( + dispatch, + () => ({ + ...getEmptyRootState(), + conversations: conversationsState, + }), + null + ); + + sinon.assert.calledWith( + window.Whisper.events.trigger as sinon.SinonSpy, + 'showConversation', + '9876', + undefined + ); + + sinon.assert.calledWith(dispatch, { + type: 'CREATE_GROUP_FULFILLED', + payload: { invitedConversationIds: ['xyz999'] }, + }); + + const fulfilledAction = dispatch.getCall(1).args[0]; + const result = reducer(conversationsState, fulfilledAction); + assert.deepEqual(result.invitedConversationIdsForNewlyCreatedGroup, [ + 'xyz999', + ]); + + sinon.assert.calledWith(dispatch, { + type: 'SWITCH_TO_ASSOCIATED_VIEW', + payload: { conversationId: '9876' }, + }); + }); + }); + describe('MESSAGE_SIZE_CHANGED', () => { const stateWithActiveConversation = { ...getEmptyState(), @@ -726,18 +1129,97 @@ describe('both/state/ducks/conversations', () => { }); }); + describe('SET_COMPOSE_GROUP_AVATAR', () => { + it("can clear the composer's group avatar", () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'foo', + groupAvatar: new ArrayBuffer(2), + isCreating: false as const, + hasError: false as const, + }, + }; + const action = setComposeGroupAvatar(undefined); + const result = reducer(state, action); + + assert( + result.composer?.step === ComposerStep.SetGroupMetadata && + result.composer.groupAvatar === undefined + ); + }); + + it("can set the composer's group avatar", () => { + const avatar = new Uint8Array([1, 2, 3]).buffer; + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'foo', + groupAvatar: undefined, + isCreating: false as const, + hasError: false as const, + }, + }; + const action = setComposeGroupAvatar(avatar); + const result = reducer(state, action); + + assert( + result.composer?.step === ComposerStep.SetGroupMetadata && + result.composer.groupAvatar === avatar + ); + }); + }); + + describe('SET_COMPOSE_GROUP_NAME', () => { + it("can set the composer's group name", () => { + const state = { + ...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, + }, + }; + const action = setComposeGroupName('bing bong'); + const result = reducer(state, action); + + assert( + result.composer?.step === ComposerStep.SetGroupMetadata && + result.composer.groupName === 'bing bong' + ); + }); + }); + describe('SET_COMPOSE_SEARCH_TERM', () => { it('updates the contact search term', () => { const state = { ...getEmptyState(), composer: { + step: ComposerStep.StartDirectConversation as const, contactSearchTerm: '', }, }; const action = setComposeSearchTerm('foo bar'); const result = reducer(state, action); - assert.strictEqual(result.composer?.contactSearchTerm, 'foo bar'); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: 'foo bar', + }); }); }); @@ -801,6 +1283,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + step: ComposerStep.StartDirectConversation as const, contactSearchTerm: '', }, }; @@ -838,6 +1321,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + step: ComposerStep.StartDirectConversation as const, contactSearchTerm: '', }, }; @@ -850,10 +1334,11 @@ describe('both/state/ducks/conversations', () => { }); describe('START_COMPOSING', () => { - it('if already at the composer, does nothing', () => { + it('does nothing if on the first step of the composer', () => { const state = { ...getEmptyState(), composer: { + step: ComposerStep.StartDirectConversation as const, contactSearchTerm: 'foo bar', }, }; @@ -861,7 +1346,58 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.isFalse(result.showArchived); - assert.deepEqual(result.composer, { contactSearchTerm: 'foo bar' }); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: 'foo bar', + }); + }); + + it('if on the second step of the composer, goes back to the first step, clearing the search term', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: undefined, + contactSearchTerm: 'to be cleared', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = startComposing(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: '', + }); + }); + + it('if on the third step of the composer, goes back to the first step, clearing everything', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: false, + hasError: false as const, + }, + }; + const action = startComposing(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: '', + }); }); it('switches from the inbox to the composer', () => { @@ -870,7 +1406,10 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.isFalse(result.showArchived); - assert.deepEqual(result.composer, { contactSearchTerm: '' }); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: '', + }); }); it('switches from the archive to the inbox', () => { @@ -882,7 +1421,520 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.isFalse(result.showArchived); - assert.deepEqual(result.composer, { contactSearchTerm: '' }); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: '', + }); + }); + }); + + describe('SHOW_CHOOSE_GROUP_MEMBERS', () => { + it('switches to the second step of the composer if on the first step', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.StartDirectConversation as const, + contactSearchTerm: 'to be cleared', + }, + }; + const action = showChooseGroupMembers(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it('does nothing if already on the second step of the composer', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: 'foo bar', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = showChooseGroupMembers(); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it('returns to the second step if on the third step of the composer', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([4, 2]).buffer, + isCreating: false, + hasError: false as const, + }, + }; + const action = showChooseGroupMembers(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([4, 2]).buffer, + }); + }); + + it('switches from the inbox to the second step of the composer', () => { + const state = getEmptyState(); + const action = showChooseGroupMembers(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it('switches from the archive to the second step of the composer', () => { + const state = { + ...getEmptyState(), + showArchived: true, + }; + const action = showChooseGroupMembers(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + }); + + describe('START_SETTING_GROUP_METADATA', () => { + it('moves from the second to the third step of the composer', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: 'foo bar', + selectedConversationIds: ['abc', 'def'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = startSettingGroupMetadata(); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.SetGroupMetadata, + selectedConversationIds: ['abc', 'def'], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: false, + hasError: false, + }); + }); + + it('maintains state when going from the second to third steps of the composer, if the second step already had some data (likely from a previous visit)', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: 'foo bar', + selectedConversationIds: ['abc', 'def'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([6, 9]).buffer, + }, + }; + const action = startSettingGroupMetadata(); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.SetGroupMetadata, + selectedConversationIds: ['abc', 'def'], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([6, 9]).buffer, + isCreating: false, + hasError: false as const, + }); + }); + + it('does nothing if already on the third step of the composer', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([4, 2]).buffer, + isCreating: false, + hasError: false as const, + }, + }; + const action = startSettingGroupMetadata(); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + }); + + describe('TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS', () => { + function getAction( + id: string, + conversationsState: ConversationsStateType + ): ToggleConversationInChooseMembersActionType { + const dispatch = sinon.spy(); + + toggleConversationInChooseMembers(id)( + dispatch, + () => ({ + ...getEmptyRootState(), + conversations: conversationsState, + }), + null + ); + + return dispatch.getCall(0).args[0]; + } + + let remoteConfigGetValueStub: sinon.SinonStub; + + beforeEach(() => { + remoteConfigGetValueStub = sinonSandbox + .stub(window.Signal.RemoteConfig, 'getValue') + .withArgs('global.groupsv2.maxGroupSize') + .returns('22') + .withArgs('global.groupsv2.groupSizeHardLimit') + .returns('33'); + }); + + it('adds conversation IDs to the list', () => { + const zero = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const one = reducer(zero, getAction('abc', zero)); + const two = reducer(one, getAction('def', one)); + + assert.deepEqual(two.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: ['abc', 'def'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it('removes conversation IDs from the list', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: ['abc', 'def'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction('abc', state); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: ['def'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it('shows the recommended group size modal when first crossing the maximum recommended group size', () => { + const oldSelectedConversationIds = times(21, () => uuid()); + const newUuid = uuid(); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: oldSelectedConversationIds, + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(newUuid, state); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [...oldSelectedConversationIds, newUuid], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Showing, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it("doesn't show the recommended group size modal twice", () => { + const oldSelectedConversationIds = times(21, () => uuid()); + const newUuid = uuid(); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: oldSelectedConversationIds, + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(newUuid, state); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [...oldSelectedConversationIds, newUuid], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it('defaults the maximum recommended size to 151', () => { + [undefined, 'xyz'].forEach(value => { + remoteConfigGetValueStub + .withArgs('global.groupsv2.maxGroupSize') + .returns(value); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(uuid(), state); + + assert.strictEqual(action.payload.maxRecommendedGroupSize, 151); + }); + }); + + it('shows the maximum group size modal when first reaching the maximum group size', () => { + const oldSelectedConversationIds = times(31, () => uuid()); + const newUuid = uuid(); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: oldSelectedConversationIds, + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(newUuid, state); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [...oldSelectedConversationIds, newUuid], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.Showing, + groupName: '', + groupAvatar: undefined, + }); + }); + + it("doesn't show the maximum group size modal twice", () => { + const oldSelectedConversationIds = times(31, () => uuid()); + const newUuid = uuid(); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: oldSelectedConversationIds, + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.Shown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(newUuid, state); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [...oldSelectedConversationIds, newUuid], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.Shown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it('cannot select more than the maximum number of conversations', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: times(1000, () => uuid()), + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(uuid(), state); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it('defaults the maximum group size to 1001 if the recommended maximum is smaller', () => { + [undefined, 'xyz'].forEach(value => { + remoteConfigGetValueStub + .withArgs('global.groupsv2.maxGroupSize') + .returns('2') + .withArgs('global.groupsv2.groupSizeHardLimit') + .returns(value); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(uuid(), state); + + assert.strictEqual(action.payload.maxGroupSize, 1001); + }); + }); + + it('defaults the maximum group size to (recommended maximum + 1) if the recommended maximum is more than 1001', () => { + remoteConfigGetValueStub + .withArgs('global.groupsv2.maxGroupSize') + .returns('1234') + .withArgs('global.groupsv2.groupSizeHardLimit') + .returns('2'); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(uuid(), state); + + assert.strictEqual(action.payload.maxGroupSize, 1235); }); }); }); diff --git a/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts new file mode 100644 index 000000000..16b20b23e --- /dev/null +++ b/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts @@ -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, + }); + }); + }); +}); diff --git a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts index 9967ac845..a17d3f930 100644 --- a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts +++ b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts @@ -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', + }) + ); }); }); }); diff --git a/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts new file mode 100644 index 000000000..28a4d88df --- /dev/null +++ b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts @@ -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 }); + }); + }); +}); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index aaef610e4..1872069bd 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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" diff --git a/ts/util/parseIntOrThrow.ts b/ts/util/parseIntOrThrow.ts new file mode 100644 index 000000000..21537f2b2 --- /dev/null +++ b/ts/util/parseIntOrThrow.ts @@ -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; +} diff --git a/ts/util/parseIntWithFallback.ts b/ts/util/parseIntWithFallback.ts new file mode 100644 index 000000000..2978bd472 --- /dev/null +++ b/ts/util/parseIntWithFallback.ts @@ -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; + } +}