Support for creating New Groups
This commit is contained in:
parent
1934120e46
commit
5de4babc0d
56 changed files with 6222 additions and 526 deletions
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
1
images/icons/v2/camera-outline-24.svg
Normal file
1
images/icons/v2/camera-outline-24.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m14.279 3 2 2.5h2.221a3 3 0 0 1 3 3v9a3 3 0 0 1 -3 3h-13a3 3 0 0 1 -3-3v-9a3 3 0 0 1 3-3h2.221l2-2.5zm0-1.5h-4.558a1.5 1.5 0 0 0 -1.171.563l-1.55 1.937h-1.5a4.5 4.5 0 0 0 -4.5 4.5v9a4.5 4.5 0 0 0 4.5 4.5h13a4.5 4.5 0 0 0 4.5-4.5v-9a4.5 4.5 0 0 0 -4.5-4.5h-1.5l-1.55-1.937a1.5 1.5 0 0 0 -1.171-.563zm-2.279 6.5a4.5 4.5 0 1 1 -4.5 4.5 4.505 4.505 0 0 1 4.5-4.5m0-1.5a6 6 0 1 0 6 6 6 6 0 0 0 -6-6z"/></svg>
|
After Width: | Height: | Size: 495 B |
|
@ -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 {
|
||||
|
|
39
stylesheets/components/Alert.scss
Normal file
39
stylesheets/components/Alert.scss
Normal file
|
@ -0,0 +1,39 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.module-Alert {
|
||||
@include popper-shadow();
|
||||
border-radius: 8px;
|
||||
margin: 0 auto;
|
||||
max-width: 360px;
|
||||
padding: 16px;
|
||||
width: 95%;
|
||||
|
||||
@include light-theme() {
|
||||
background: $color-white;
|
||||
color: $color-gray-90;
|
||||
}
|
||||
|
||||
@include dark-theme() {
|
||||
background: $color-gray-95;
|
||||
color: $color-gray-05;
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include font-body-1-bold;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__body {
|
||||
@include font-body-1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__button-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
77
stylesheets/components/AvatarInput.scss
Normal file
77
stylesheets/components/AvatarInput.scss
Normal file
|
@ -0,0 +1,77 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.module-AvatarInput {
|
||||
@include button-reset;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: none;
|
||||
|
||||
&__avatar {
|
||||
@include button-reset;
|
||||
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
border-radius: 100%;
|
||||
height: 80px;
|
||||
width: 80px;
|
||||
transition: background-color 100ms ease-out;
|
||||
|
||||
&--nothing {
|
||||
align-items: stretch;
|
||||
background: $color-white;
|
||||
|
||||
&::before {
|
||||
flex-grow: 1;
|
||||
content: '';
|
||||
display: block;
|
||||
@include color-svg(
|
||||
'../images/icons/v2/camera-outline-24.svg',
|
||||
$ultramarine-ui-light,
|
||||
false
|
||||
);
|
||||
-webkit-mask-size: 24px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&--loading {
|
||||
align-items: center;
|
||||
background: $color-black;
|
||||
}
|
||||
|
||||
&--has-image {
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
@include button-reset;
|
||||
@include font-body-1;
|
||||
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
|
||||
@include light-theme {
|
||||
color: $ultramarine-ui-light;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
color: $ultramarine-ui-dark;
|
||||
}
|
||||
}
|
||||
|
||||
@include keyboard-mode {
|
||||
&:focus {
|
||||
.module-AvatarInput__avatar {
|
||||
box-shadow: inset 0 0 0 2px $ultramarine-ui-light;
|
||||
}
|
||||
|
||||
.module-AvatarInput__label {
|
||||
@include font-body-1-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
72
stylesheets/components/ContactPill.scss
Normal file
72
stylesheets/components/ContactPill.scss
Normal file
|
@ -0,0 +1,72 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.module-ContactPill {
|
||||
align-items: center;
|
||||
border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.)
|
||||
display: inline-flex;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
|
||||
@include light-theme {
|
||||
color: $color-gray-90;
|
||||
background: $color-gray-05;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-02;
|
||||
background: $color-gray-75;
|
||||
}
|
||||
|
||||
&__contact-name {
|
||||
@include font-body-2;
|
||||
padding: 0 6px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
$icon: '../images/icons/v2/x-24.svg';
|
||||
|
||||
@include button-reset;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
width: 28px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 6px 0 4px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: block;
|
||||
|
||||
@include light-theme {
|
||||
@include color-svg($icon, $color-gray-60);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg($icon, $color-gray-25);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyboard-mode {
|
||||
&:focus {
|
||||
background: $color-gray-15;
|
||||
|
||||
&::before {
|
||||
@include color-svg($icon, $ultramarine-ui-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
@include dark-keyboard-mode {
|
||||
&:focus {
|
||||
background: $color-gray-65;
|
||||
|
||||
&::before {
|
||||
@include color-svg($icon, $ultramarine-ui-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
20
stylesheets/components/ContactPills.scss
Normal file
20
stylesheets/components/ContactPills.scss
Normal file
|
@ -0,0 +1,20 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.module-ContactPills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
max-height: 88px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
padding-left: 12px;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
.module-ContactPill {
|
||||
margin: 4px 6px;
|
||||
max-width: calc(
|
||||
100% - 15px
|
||||
); // 6px for the right margin and 9px for the scrollbar
|
||||
}
|
||||
}
|
121
stylesheets/components/GroupDialog.scss
Normal file
121
stylesheets/components/GroupDialog.scss
Normal file
|
@ -0,0 +1,121 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.module-GroupDialog {
|
||||
@include popper-shadow();
|
||||
border-radius: 8px;
|
||||
margin: 0 auto;
|
||||
max-height: 100%;
|
||||
max-width: 360px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
width: 95%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@include light-theme() {
|
||||
background: $color-white;
|
||||
color: $color-gray-90;
|
||||
}
|
||||
|
||||
@include dark-theme() {
|
||||
background: $color-gray-95;
|
||||
color: $color-gray-05;
|
||||
}
|
||||
|
||||
&__close-button {
|
||||
@include button-reset;
|
||||
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
@include light-theme {
|
||||
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-15);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@include keyboard-mode {
|
||||
background-color: $ultramarine-ui-light;
|
||||
}
|
||||
@include dark-keyboard-mode {
|
||||
background-color: $ultramarine-ui-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include font-title-2;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__body {
|
||||
overflow-x: scroll;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
&__paragraph,
|
||||
&__contacts {
|
||||
margin: 0 0 16px 0;
|
||||
padding: 0 16px 0 28px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 11px;
|
||||
left: 4px;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
width: 4px;
|
||||
|
||||
@include light-theme {
|
||||
background-color: $color-gray-15;
|
||||
}
|
||||
@include dark-theme {
|
||||
background-color: $color-gray-65;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__contacts {
|
||||
list-style-type: none;
|
||||
|
||||
&__contact {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__contact__name {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__button-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
.module-Button {
|
||||
flex-grow: 1;
|
||||
max-width: 152px;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
import { get, throttle } from 'lodash';
|
||||
import { WebAPIType } from './textsecure/WebAPI';
|
||||
|
||||
type ConfigKeyType =
|
||||
export type ConfigKeyType =
|
||||
| 'desktop.cds'
|
||||
| 'desktop.clientExpiration'
|
||||
| 'desktop.disableGV1'
|
||||
|
|
32
ts/components/Alert.tsx
Normal file
32
ts/components/Alert.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { FunctionComponent } from 'react';
|
||||
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { Button } from './Button';
|
||||
import { ModalHost } from './ModalHost';
|
||||
|
||||
type PropsType = {
|
||||
title?: string;
|
||||
body: string;
|
||||
i18n: LocalizerType;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const Alert: FunctionComponent<PropsType> = ({
|
||||
body,
|
||||
i18n,
|
||||
onClose,
|
||||
title,
|
||||
}) => (
|
||||
<ModalHost onClose={onClose}>
|
||||
<div className="module-Alert">
|
||||
{title && <h1 className="module-Alert__title">{title}</h1>}
|
||||
<p className="module-Alert__body">{body}</p>
|
||||
<div className="module-Alert__button-container">
|
||||
<Button onClick={onClose}>{i18n('Confirmation--confirm')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalHost>
|
||||
);
|
69
ts/components/AvatarInput.stories.tsx
Normal file
69
ts/components/AvatarInput.stories.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { chunk, noop } from 'lodash';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { AvatarInput } from './AvatarInput';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf('Components/AvatarInput', module);
|
||||
|
||||
const TEST_IMAGE = new Uint8Array(
|
||||
chunk(
|
||||
'89504e470d0a1a0a0000000d4948445200000008000000080103000000fec12cc800000006504c5445ff00ff00ff000c82e9800000001849444154085b633061a8638863a867f8c720c760c12000001a4302f4d81dd9870000000049454e44ae426082',
|
||||
2
|
||||
).map(bytePair => parseInt(bytePair.join(''), 16))
|
||||
).buffer;
|
||||
|
||||
const Wrapper = ({ startValue }: { startValue: undefined | ArrayBuffer }) => {
|
||||
const [value, setValue] = useState<undefined | ArrayBuffer>(startValue);
|
||||
const [objectUrl, setObjectUrl] = useState<undefined | string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setObjectUrl(undefined);
|
||||
return noop;
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([value]));
|
||||
setObjectUrl(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(255, 0, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<AvatarInput
|
||||
contextMenuId={uuid()}
|
||||
i18n={i18n}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
<figure>
|
||||
<figcaption>Processed image (if it exists)</figcaption>
|
||||
{objectUrl && <img src={objectUrl} alt="" />}
|
||||
</figure>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
story.add('No start state', () => {
|
||||
return <Wrapper startValue={undefined} />;
|
||||
});
|
||||
|
||||
story.add('Starting with a value', () => {
|
||||
return <Wrapper startValue={TEST_IMAGE} />;
|
||||
});
|
213
ts/components/AvatarInput.tsx
Normal file
213
ts/components/AvatarInput.tsx
Normal file
|
@ -0,0 +1,213 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
ChangeEventHandler,
|
||||
MouseEventHandler,
|
||||
FunctionComponent,
|
||||
} from 'react';
|
||||
import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu';
|
||||
import loadImage, { LoadImageOptions } from 'blueimp-load-image';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
type PropsType = {
|
||||
// This ID needs to be globally unique across the app.
|
||||
contextMenuId: string;
|
||||
disabled?: boolean;
|
||||
i18n: LocalizerType;
|
||||
onChange: (value: undefined | ArrayBuffer) => unknown;
|
||||
value: undefined | ArrayBuffer;
|
||||
};
|
||||
|
||||
enum ImageStatus {
|
||||
Nothing = 'nothing',
|
||||
Loading = 'loading',
|
||||
HasImage = 'has-image',
|
||||
}
|
||||
|
||||
export const AvatarInput: FunctionComponent<PropsType> = ({
|
||||
contextMenuId,
|
||||
disabled,
|
||||
i18n,
|
||||
onChange,
|
||||
value,
|
||||
}) => {
|
||||
const fileInputRef = useRef<null | HTMLInputElement>(null);
|
||||
// Comes from a third-party dependency
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const menuTriggerRef = useRef<null | any>(null);
|
||||
|
||||
const [objectUrl, setObjectUrl] = useState<undefined | string>();
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setObjectUrl(undefined);
|
||||
return noop;
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([value]));
|
||||
setObjectUrl(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const [processingFile, setProcessingFile] = useState<undefined | File>(
|
||||
undefined
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!processingFile) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
let shouldCancel = false;
|
||||
|
||||
(async () => {
|
||||
let newValue: ArrayBuffer;
|
||||
try {
|
||||
newValue = await processFile(processingFile);
|
||||
} catch (err) {
|
||||
// Processing errors should be rare; if they do, we silently fail. In an ideal
|
||||
// world, we may want to show a toast instead.
|
||||
return;
|
||||
}
|
||||
if (shouldCancel) {
|
||||
return;
|
||||
}
|
||||
setProcessingFile(undefined);
|
||||
onChange(newValue);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
shouldCancel = true;
|
||||
};
|
||||
}, [processingFile, onChange]);
|
||||
|
||||
const buttonLabel = value
|
||||
? i18n('AvatarInput--change-photo-label')
|
||||
: i18n('AvatarInput--no-photo-label--group');
|
||||
|
||||
const startUpload = () => {
|
||||
const fileInput = fileInputRef.current;
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
onChange(undefined);
|
||||
};
|
||||
|
||||
const onClick: MouseEventHandler<unknown> = value
|
||||
? event => {
|
||||
const menuTrigger = menuTriggerRef.current;
|
||||
if (!menuTrigger) {
|
||||
return;
|
||||
}
|
||||
menuTrigger.handleContextClick(event);
|
||||
}
|
||||
: startUpload;
|
||||
|
||||
const onInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
if (file) {
|
||||
setProcessingFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
let imageStatus: ImageStatus;
|
||||
if (processingFile || (value && !objectUrl)) {
|
||||
imageStatus = ImageStatus.Loading;
|
||||
} else if (objectUrl) {
|
||||
imageStatus = ImageStatus.HasImage;
|
||||
} else {
|
||||
imageStatus = ImageStatus.Nothing;
|
||||
}
|
||||
|
||||
const isLoading = imageStatus === ImageStatus.Loading;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenuTrigger id={contextMenuId} ref={menuTriggerRef}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isLoading}
|
||||
className="module-AvatarInput"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={`module-AvatarInput__avatar module-AvatarInput__avatar--${imageStatus}`}
|
||||
style={
|
||||
imageStatus === ImageStatus.HasImage
|
||||
? {
|
||||
backgroundImage: `url(${objectUrl})`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isLoading && (
|
||||
<Spinner size="70px" svgSize="normal" direction="on-avatar" />
|
||||
)}
|
||||
</div>
|
||||
<span className="module-AvatarInput__label">{buttonLabel}</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenu id={contextMenuId}>
|
||||
<MenuItem onClick={startUpload}>
|
||||
{i18n('AvatarInput--upload-photo-choice')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={clear}>
|
||||
{i18n('AvatarInput--remove-photo-choice')}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
<input
|
||||
accept=".gif,.jpg,.jpeg,.png,.webp,image/gif,image/jpeg/image/png,image/webp"
|
||||
hidden
|
||||
onChange={onInputChange}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
async function processFile(file: File): Promise<ArrayBuffer> {
|
||||
const { image } = await loadImage(file, {
|
||||
canvas: true,
|
||||
cover: true,
|
||||
crop: true,
|
||||
imageSmoothingQuality: 'medium',
|
||||
maxHeight: 512,
|
||||
maxWidth: 512,
|
||||
minHeight: 2,
|
||||
minWidth: 2,
|
||||
// `imageSmoothingQuality` is not present in `loadImage`'s types, but it is
|
||||
// documented and supported. Updating DefinitelyTyped is the long-term solution
|
||||
// here.
|
||||
} as LoadImageOptions);
|
||||
|
||||
// NOTE: The types for `loadImage` say this can never be a canvas, but it will be if
|
||||
// `canvas: true`, at least in our case. Again, updating DefinitelyTyped should
|
||||
// address this.
|
||||
if (!(image instanceof HTMLCanvasElement)) {
|
||||
throw new Error('Loaded image was not a canvas');
|
||||
}
|
||||
|
||||
return (await canvasToBlob(image)).arrayBuffer();
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(blob => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error("Couldn't convert the canvas to a Blob"));
|
||||
}
|
||||
}, 'image/webp');
|
||||
});
|
||||
}
|
74
ts/components/ContactPill.tsx
Normal file
74
ts/components/ContactPill.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { FunctionComponent } from 'react';
|
||||
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
|
||||
export type PropsType = {
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
firstName?: string;
|
||||
i18n: LocalizerType;
|
||||
id: string;
|
||||
isMe?: boolean;
|
||||
name?: string;
|
||||
onClickRemove: (id: string) => void;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const ContactPill: FunctionComponent<PropsType> = ({
|
||||
avatarPath,
|
||||
color,
|
||||
firstName,
|
||||
i18n,
|
||||
id,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
title,
|
||||
onClickRemove,
|
||||
}) => {
|
||||
const removeLabel = i18n('ContactPill--remove');
|
||||
|
||||
return (
|
||||
<div className="module-ContactPill">
|
||||
<Avatar
|
||||
avatarPath={avatarPath}
|
||||
color={color}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
title={title}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
/>
|
||||
<ContactName
|
||||
firstName={firstName}
|
||||
i18n={i18n}
|
||||
module="module-ContactPill__contact-name"
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
preferFirstName
|
||||
profileName={profileName}
|
||||
title={title}
|
||||
/>
|
||||
<button
|
||||
aria-label={removeLabel}
|
||||
className="module-ContactPill__remove"
|
||||
onClick={() => {
|
||||
onClickRemove(id);
|
||||
}}
|
||||
title={removeLabel}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
87
ts/components/ContactPills.stories.tsx
Normal file
87
ts/components/ContactPills.stories.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { times } from 'lodash';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { ContactPills } from './ContactPills';
|
||||
import { ContactPill, PropsType as ContactPillPropsType } from './ContactPill';
|
||||
import { gifUrl } from '../storybook/Fixtures';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf('Components/Contact Pills', module);
|
||||
|
||||
type ContactType = Omit<ContactPillPropsType, 'i18n' | 'onClickRemove'>;
|
||||
|
||||
const contacts: Array<ContactType> = times(50, index => ({
|
||||
color: 'red',
|
||||
id: `contact-${index}`,
|
||||
isMe: false,
|
||||
name: `Contact ${index}`,
|
||||
phoneNumber: '(202) 555-0001',
|
||||
profileName: `C${index}`,
|
||||
title: `Contact ${index}`,
|
||||
}));
|
||||
|
||||
const contactPillProps = (
|
||||
overrideProps?: ContactType
|
||||
): ContactPillPropsType => ({
|
||||
...(overrideProps || {
|
||||
avatarPath: gifUrl,
|
||||
color: 'red',
|
||||
firstName: 'John',
|
||||
id: 'abc123',
|
||||
isMe: false,
|
||||
name: 'John Bon Bon Jovi',
|
||||
phoneNumber: '(202) 555-0001',
|
||||
profileName: 'JohnB',
|
||||
title: 'John Bon Bon Jovi',
|
||||
}),
|
||||
i18n,
|
||||
onClickRemove: action('onClickRemove'),
|
||||
});
|
||||
|
||||
story.add('Empty list', () => <ContactPills />);
|
||||
|
||||
story.add('One contact', () => (
|
||||
<ContactPills>
|
||||
<ContactPill {...contactPillProps()} />
|
||||
</ContactPills>
|
||||
));
|
||||
|
||||
story.add('Three contacts', () => (
|
||||
<ContactPills>
|
||||
<ContactPill {...contactPillProps(contacts[0])} />
|
||||
<ContactPill {...contactPillProps(contacts[1])} />
|
||||
<ContactPill {...contactPillProps(contacts[2])} />
|
||||
</ContactPills>
|
||||
));
|
||||
|
||||
story.add('Four contacts, one with a long name', () => (
|
||||
<ContactPills>
|
||||
<ContactPill {...contactPillProps(contacts[0])} />
|
||||
<ContactPill
|
||||
{...contactPillProps({
|
||||
...contacts[1],
|
||||
title:
|
||||
'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso',
|
||||
})}
|
||||
/>
|
||||
<ContactPill {...contactPillProps(contacts[2])} />
|
||||
<ContactPill {...contactPillProps(contacts[3])} />
|
||||
</ContactPills>
|
||||
));
|
||||
|
||||
story.add('Fifty contacts', () => (
|
||||
<ContactPills>
|
||||
{contacts.map(contact => (
|
||||
<ContactPill key={contact.id} {...contactPillProps(contact)} />
|
||||
))}
|
||||
</ContactPills>
|
||||
));
|
38
ts/components/ContactPills.tsx
Normal file
38
ts/components/ContactPills.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
Children,
|
||||
FunctionComponent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
type PropsType = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const ContactPills: FunctionComponent<PropsType> = ({ children }) => {
|
||||
const elRef = useRef<null | HTMLDivElement>(null);
|
||||
|
||||
const childCount = Children.count(children);
|
||||
const previousChildCountRef = useRef<number>(childCount);
|
||||
const previousChildCount = previousChildCountRef.current;
|
||||
previousChildCountRef.current = childCount;
|
||||
|
||||
useEffect(() => {
|
||||
const hasAddedNewChild = childCount > previousChildCount;
|
||||
const el = elRef.current;
|
||||
if (!hasAddedNewChild || !el) {
|
||||
return;
|
||||
}
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [childCount, previousChildCount]);
|
||||
|
||||
return (
|
||||
<div className="module-ContactPills" ref={elRef}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -14,6 +14,7 @@ import {
|
|||
PropsData as ConversationListItemPropsType,
|
||||
MessageStatuses,
|
||||
} from './conversationList/ConversationListItem';
|
||||
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
|
@ -39,6 +40,15 @@ const defaultConversations: Array<ConversationListItemPropsType> = [
|
|||
title: 'Marc Barraca',
|
||||
type: 'direct',
|
||||
},
|
||||
{
|
||||
id: 'long-name-convo',
|
||||
isSelected: false,
|
||||
lastUpdated: Date.now(),
|
||||
markedUnread: false,
|
||||
title:
|
||||
'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso',
|
||||
type: 'direct',
|
||||
},
|
||||
];
|
||||
|
||||
const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
|
||||
|
@ -52,6 +62,7 @@ const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
|
|||
i18n,
|
||||
onSelectConversation: action('onSelectConversation'),
|
||||
onClickArchiveButton: action('onClickArchiveButton'),
|
||||
onClickContactCheckbox: action('onClickContactCheckbox'),
|
||||
renderMessageSearchResult: (id: string, style: React.CSSProperties) => (
|
||||
<MessageSearchResult
|
||||
conversationId="marc-convo"
|
||||
|
@ -65,6 +76,7 @@ const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
|
|||
to={defaultConversations[1]}
|
||||
/>
|
||||
),
|
||||
showChooseGroupMembers: action('showChooseGroupMembers'),
|
||||
startNewConversationFromPhoneNumber: action(
|
||||
'startNewConversationFromPhoneNumber'
|
||||
),
|
||||
|
@ -144,6 +156,56 @@ story.add('Contact: group', () => (
|
|||
/>
|
||||
));
|
||||
|
||||
story.add('Contact checkboxes', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
{
|
||||
type: RowType.ContactCheckbox,
|
||||
contact: defaultConversations[0],
|
||||
isChecked: true,
|
||||
},
|
||||
{
|
||||
type: RowType.ContactCheckbox,
|
||||
contact: defaultConversations[1],
|
||||
isChecked: false,
|
||||
},
|
||||
{
|
||||
type: RowType.ContactCheckbox,
|
||||
contact: {
|
||||
...defaultConversations[2],
|
||||
about: '😃 Hola',
|
||||
},
|
||||
isChecked: true,
|
||||
},
|
||||
])}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Contact checkboxes: disabled', () => (
|
||||
<ConversationList
|
||||
{...createProps([
|
||||
{
|
||||
type: RowType.ContactCheckbox,
|
||||
contact: defaultConversations[0],
|
||||
isChecked: false,
|
||||
disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected,
|
||||
},
|
||||
{
|
||||
type: RowType.ContactCheckbox,
|
||||
contact: defaultConversations[1],
|
||||
isChecked: false,
|
||||
disabledReason: ContactCheckboxDisabledReason.NotCapable,
|
||||
},
|
||||
{
|
||||
type: RowType.ContactCheckbox,
|
||||
contact: defaultConversations[2],
|
||||
isChecked: true,
|
||||
disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected,
|
||||
},
|
||||
])}
|
||||
/>
|
||||
));
|
||||
|
||||
{
|
||||
const createConversation = (
|
||||
overrideProps: Partial<ConversationListItemPropsType> = {}
|
||||
|
|
|
@ -16,13 +16,21 @@ import {
|
|||
ContactListItem,
|
||||
PropsDataType as ContactListItemPropsType,
|
||||
} from './conversationList/ContactListItem';
|
||||
import {
|
||||
ContactCheckbox as ContactCheckboxComponent,
|
||||
ContactCheckboxDisabledReason,
|
||||
} from './conversationList/ContactCheckbox';
|
||||
import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton';
|
||||
import { Spinner as SpinnerComponent } from './Spinner';
|
||||
import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation';
|
||||
|
||||
export enum RowType {
|
||||
ArchiveButton,
|
||||
Blank,
|
||||
Contact,
|
||||
ContactCheckbox,
|
||||
Conversation,
|
||||
CreateNewGroup,
|
||||
Header,
|
||||
MessageSearchResult,
|
||||
Spinner,
|
||||
|
@ -34,9 +42,19 @@ type ArchiveButtonRowType = {
|
|||
archivedConversationsCount: number;
|
||||
};
|
||||
|
||||
type BlankRowType = { type: RowType.Blank };
|
||||
|
||||
type ContactRowType = {
|
||||
type: RowType.Contact;
|
||||
contact: ContactListItemPropsType;
|
||||
isClickable?: boolean;
|
||||
};
|
||||
|
||||
type ContactCheckboxRowType = {
|
||||
type: RowType.ContactCheckbox;
|
||||
contact: ContactListItemPropsType;
|
||||
isChecked: boolean;
|
||||
disabledReason?: ContactCheckboxDisabledReason;
|
||||
};
|
||||
|
||||
type ConversationRowType = {
|
||||
|
@ -44,6 +62,10 @@ type ConversationRowType = {
|
|||
conversation: ConversationListItemPropsType;
|
||||
};
|
||||
|
||||
type CreateNewGroupRowType = {
|
||||
type: RowType.CreateNewGroup;
|
||||
};
|
||||
|
||||
type MessageRowType = {
|
||||
type: RowType.MessageSearchResult;
|
||||
messageId: string;
|
||||
|
@ -63,8 +85,11 @@ type StartNewConversationRowType = {
|
|||
|
||||
export type Row =
|
||||
| ArchiveButtonRowType
|
||||
| BlankRowType
|
||||
| ContactRowType
|
||||
| ContactCheckboxRowType
|
||||
| ConversationRowType
|
||||
| CreateNewGroupRowType
|
||||
| MessageRowType
|
||||
| HeaderRowType
|
||||
| SpinnerRowType
|
||||
|
@ -85,9 +110,14 @@ export type PropsType = {
|
|||
|
||||
i18n: LocalizerType;
|
||||
|
||||
onSelectConversation: (conversationId: string, messageId?: string) => void;
|
||||
onClickArchiveButton: () => void;
|
||||
onClickContactCheckbox: (
|
||||
conversationId: string,
|
||||
disabledReason: undefined | ContactCheckboxDisabledReason
|
||||
) => void;
|
||||
onSelectConversation: (conversationId: string, messageId?: string) => void;
|
||||
renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element;
|
||||
showChooseGroupMembers: () => void;
|
||||
startNewConversationFromPhoneNumber: (e164: string) => void;
|
||||
};
|
||||
|
||||
|
@ -96,11 +126,13 @@ export const ConversationList: React.FC<PropsType> = ({
|
|||
getRow,
|
||||
i18n,
|
||||
onClickArchiveButton,
|
||||
onClickContactCheckbox,
|
||||
onSelectConversation,
|
||||
renderMessageSearchResult,
|
||||
rowCount,
|
||||
scrollToRowIndex,
|
||||
shouldRecomputeRowHeights,
|
||||
showChooseGroupMembers,
|
||||
startNewConversationFromPhoneNumber,
|
||||
}) => {
|
||||
const listRef = useRef<null | List>(null);
|
||||
|
@ -148,13 +180,29 @@ export const ConversationList: React.FC<PropsType> = ({
|
|||
</span>
|
||||
</button>
|
||||
);
|
||||
case RowType.Contact:
|
||||
case RowType.Blank:
|
||||
return <div key={key} style={style} />;
|
||||
case RowType.Contact: {
|
||||
const { isClickable = true } = row;
|
||||
return (
|
||||
<ContactListItem
|
||||
{...row.contact}
|
||||
key={key}
|
||||
style={style}
|
||||
onClick={onSelectConversation}
|
||||
onClick={isClickable ? onSelectConversation : undefined}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case RowType.ContactCheckbox:
|
||||
return (
|
||||
<ContactCheckboxComponent
|
||||
{...row.contact}
|
||||
isChecked={row.isChecked}
|
||||
disabledReason={row.disabledReason}
|
||||
key={key}
|
||||
style={style}
|
||||
onClick={onClickContactCheckbox}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
|
@ -168,6 +216,15 @@ export const ConversationList: React.FC<PropsType> = ({
|
|||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
case RowType.CreateNewGroup:
|
||||
return (
|
||||
<CreateNewGroupButton
|
||||
i18n={i18n}
|
||||
key={key}
|
||||
onClick={showChooseGroupMembers}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
case RowType.Header:
|
||||
return (
|
||||
<div
|
||||
|
@ -214,8 +271,10 @@ export const ConversationList: React.FC<PropsType> = ({
|
|||
getRow,
|
||||
i18n,
|
||||
onClickArchiveButton,
|
||||
onClickContactCheckbox,
|
||||
onSelectConversation,
|
||||
renderMessageSearchResult,
|
||||
showChooseGroupMembers,
|
||||
startNewConversationFromPhoneNumber,
|
||||
]
|
||||
);
|
||||
|
|
120
ts/components/GroupDialog.tsx
Normal file
120
ts/components/GroupDialog.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { ReactChild, ReactNode } from 'react';
|
||||
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { ModalHost } from './ModalHost';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
|
||||
type PropsType = {
|
||||
children: ReactNode;
|
||||
i18n: LocalizerType;
|
||||
onClickPrimaryButton: () => void;
|
||||
onClose: () => void;
|
||||
primaryButtonText: string;
|
||||
title: string;
|
||||
} & (
|
||||
| // We use this empty type for an "all or nothing" setup.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
{}
|
||||
| {
|
||||
onClickSecondaryButton: () => void;
|
||||
secondaryButtonText: string;
|
||||
}
|
||||
);
|
||||
|
||||
export function GroupDialog(props: Readonly<PropsType>): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
i18n,
|
||||
onClickPrimaryButton,
|
||||
onClose,
|
||||
primaryButtonText,
|
||||
title,
|
||||
} = props;
|
||||
|
||||
let secondaryButton: undefined | ReactChild;
|
||||
if ('secondaryButtonText' in props) {
|
||||
const { onClickSecondaryButton, secondaryButtonText } = props;
|
||||
secondaryButton = (
|
||||
<Button
|
||||
onClick={onClickSecondaryButton}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalHost onClose={onClose}>
|
||||
<div className="module-GroupDialog">
|
||||
<button
|
||||
aria-label={i18n('close')}
|
||||
type="button"
|
||||
className="module-GroupDialog__close-button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<h1 className="module-GroupDialog__title">{title}</h1>
|
||||
<div className="module-GroupDialog__body">{children}</div>
|
||||
<div className="module-GroupDialog__button-container">
|
||||
{secondaryButton}
|
||||
<Button
|
||||
onClick={onClickPrimaryButton}
|
||||
ref={focusRef}
|
||||
variant={ButtonVariant.Primary}
|
||||
>
|
||||
{primaryButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalHost>
|
||||
);
|
||||
}
|
||||
|
||||
type ParagraphPropsType = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
GroupDialog.Paragraph = ({
|
||||
children,
|
||||
}: Readonly<ParagraphPropsType>): JSX.Element => (
|
||||
<p className="module-GroupDialog__paragraph">{children}</p>
|
||||
);
|
||||
|
||||
type ContactsPropsType = {
|
||||
contacts: Array<ConversationType>;
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
|
||||
GroupDialog.Contacts = ({ contacts, i18n }: Readonly<ContactsPropsType>) => (
|
||||
<ul className="module-GroupDialog__contacts">
|
||||
{contacts.map(contact => (
|
||||
<li key={contact.id} className="module-GroupDialog__contacts__contact">
|
||||
<Avatar
|
||||
{...contact}
|
||||
conversationType={contact.type}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
i18n={i18n}
|
||||
/>
|
||||
<ContactName
|
||||
i18n={i18n}
|
||||
module="module-GroupDialog__contacts__contact__name"
|
||||
title={contact.title}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
function focusRef(el: HTMLElement | null) {
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
}
|
|
@ -2,10 +2,9 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { Avatar } from './Avatar';
|
||||
import { GroupDialog } from './GroupDialog';
|
||||
import { sortByTitle } from '../util/sortByTitle';
|
||||
|
||||
type CallbackType = () => unknown;
|
||||
|
@ -25,61 +24,64 @@ export type HousekeepingPropsType = {
|
|||
|
||||
export type PropsType = DataPropsType & HousekeepingPropsType;
|
||||
|
||||
function focusRef(el: HTMLElement | null) {
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
export const GroupV1MigrationDialog: React.FunctionComponent<PropsType> = React.memo(
|
||||
(props: PropsType) => {
|
||||
const {
|
||||
areWeInvited,
|
||||
droppedMembers,
|
||||
hasMigrated,
|
||||
i18n,
|
||||
invitedMembers,
|
||||
migrate,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
export const GroupV1MigrationDialog = React.memo((props: PropsType) => {
|
||||
const {
|
||||
areWeInvited,
|
||||
droppedMembers,
|
||||
hasMigrated,
|
||||
i18n,
|
||||
invitedMembers,
|
||||
migrate,
|
||||
onClose,
|
||||
} = props;
|
||||
const title = hasMigrated
|
||||
? i18n('GroupV1--Migration--info--title')
|
||||
: i18n('GroupV1--Migration--migrate--title');
|
||||
const keepHistory = hasMigrated
|
||||
? i18n('GroupV1--Migration--info--keep-history')
|
||||
: i18n('GroupV1--Migration--migrate--keep-history');
|
||||
const migrationKey = hasMigrated ? 'after' : 'before';
|
||||
const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`;
|
||||
|
||||
const title = hasMigrated
|
||||
? i18n('GroupV1--Migration--info--title')
|
||||
: i18n('GroupV1--Migration--migrate--title');
|
||||
const keepHistory = hasMigrated
|
||||
? i18n('GroupV1--Migration--info--keep-history')
|
||||
: i18n('GroupV1--Migration--migrate--keep-history');
|
||||
const migrationKey = hasMigrated ? 'after' : 'before';
|
||||
const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`;
|
||||
let primaryButtonText: string;
|
||||
let onClickPrimaryButton: () => void;
|
||||
let secondaryButtonProps:
|
||||
| undefined
|
||||
| {
|
||||
secondaryButtonText: string;
|
||||
onClickSecondaryButton: () => void;
|
||||
};
|
||||
if (hasMigrated) {
|
||||
primaryButtonText = i18n('Confirmation--confirm');
|
||||
onClickPrimaryButton = onClose;
|
||||
} else {
|
||||
primaryButtonText = i18n('GroupV1--Migration--migrate');
|
||||
onClickPrimaryButton = migrate;
|
||||
secondaryButtonProps = {
|
||||
secondaryButtonText: i18n('cancel'),
|
||||
onClickSecondaryButton: onClose,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-group-v2-migration-dialog">
|
||||
<button
|
||||
aria-label={i18n('close')}
|
||||
type="button"
|
||||
className="module-group-v2-migration-dialog__close-button"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="module-group-v2-migration-dialog__title">{title}</div>
|
||||
<div className="module-group-v2-migration-dialog__scrollable">
|
||||
<div className="module-group-v2-migration-dialog__item">
|
||||
<div className="module-group-v2-migration-dialog__item__bullet" />
|
||||
<div className="module-group-v2-migration-dialog__item__content">
|
||||
{i18n('GroupV1--Migration--info--summary')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-group-v2-migration-dialog__item">
|
||||
<div className="module-group-v2-migration-dialog__item__bullet" />
|
||||
<div className="module-group-v2-migration-dialog__item__content">
|
||||
{keepHistory}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<GroupDialog
|
||||
i18n={i18n}
|
||||
onClickPrimaryButton={onClickPrimaryButton}
|
||||
onClose={onClose}
|
||||
primaryButtonText={primaryButtonText}
|
||||
title={title}
|
||||
{...secondaryButtonProps}
|
||||
>
|
||||
<GroupDialog.Paragraph>
|
||||
{i18n('GroupV1--Migration--info--summary')}
|
||||
</GroupDialog.Paragraph>
|
||||
<GroupDialog.Paragraph>{keepHistory}</GroupDialog.Paragraph>
|
||||
{areWeInvited ? (
|
||||
<div className="module-group-v2-migration-dialog__item">
|
||||
<div className="module-group-v2-migration-dialog__item__bullet" />
|
||||
<div className="module-group-v2-migration-dialog__item__content">
|
||||
{i18n('GroupV1--Migration--info--invited--you')}
|
||||
</div>
|
||||
</div>
|
||||
<GroupDialog.Paragraph>
|
||||
{i18n('GroupV1--Migration--info--invited--you')}
|
||||
</GroupDialog.Paragraph>
|
||||
) : (
|
||||
<>
|
||||
{renderMembers(
|
||||
|
@ -90,67 +92,16 @@ export const GroupV1MigrationDialog = React.memo((props: PropsType) => {
|
|||
{renderMembers(droppedMembers, droppedMembersKey, i18n)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{renderButtons(hasMigrated, onClose, migrate, i18n)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function renderButtons(
|
||||
hasMigrated: boolean,
|
||||
onClose: CallbackType,
|
||||
migrate: CallbackType,
|
||||
i18n: LocalizerType
|
||||
) {
|
||||
if (hasMigrated) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-group-v2-migration-dialog__buttons',
|
||||
'module-group-v2-migration-dialog__buttons--narrow'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className="module-group-v2-migration-dialog__button"
|
||||
ref={focusRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
{i18n('Confirmation--confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</GroupDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-group-v2-migration-dialog__buttons">
|
||||
<button
|
||||
className={classNames(
|
||||
'module-group-v2-migration-dialog__button',
|
||||
'module-group-v2-migration-dialog__button--secondary'
|
||||
)}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
{i18n('cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="module-group-v2-migration-dialog__button"
|
||||
ref={focusRef}
|
||||
type="button"
|
||||
onClick={migrate}
|
||||
>
|
||||
{i18n('GroupV1--Migration--migrate')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function renderMembers(
|
||||
members: Array<ConversationType>,
|
||||
prefix: string,
|
||||
i18n: LocalizerType
|
||||
): React.ReactElement | null {
|
||||
): React.ReactNode {
|
||||
if (!members.length) {
|
||||
return null;
|
||||
}
|
||||
|
@ -159,27 +110,9 @@ function renderMembers(
|
|||
const key = `${prefix}${postfix}`;
|
||||
|
||||
return (
|
||||
<div className="module-group-v2-migration-dialog__item">
|
||||
<div className="module-group-v2-migration-dialog__item__bullet" />
|
||||
<div className="module-group-v2-migration-dialog__item__content">
|
||||
<div>{i18n(key)}</div>
|
||||
{sortByTitle(members).map(member => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="module-group-v2-migration-dialog__member"
|
||||
>
|
||||
<Avatar
|
||||
{...member}
|
||||
conversationType={member.type}
|
||||
size={28}
|
||||
i18n={i18n}
|
||||
/>{' '}
|
||||
<span className="module-group-v2-migration-dialog__member__name">
|
||||
{member.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<GroupDialog.Paragraph>{i18n(key)}</GroupDialog.Paragraph>
|
||||
<GroupDialog.Contacts contacts={sortByTitle(members)} i18n={i18n} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -77,6 +77,12 @@ const defaultModeSpecificProps = {
|
|||
const emptySearchResultsGroup = { isLoading: false, results: [] };
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
cantAddContactToGroup: action('cantAddContactToGroup'),
|
||||
clearGroupCreationError: action('clearGroupCreationError'),
|
||||
closeCantAddContactToGroupModal: action('closeCantAddContactToGroupModal'),
|
||||
closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'),
|
||||
closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'),
|
||||
createGroup: action('createGroup'),
|
||||
i18n,
|
||||
modeSpecificProps: defaultModeSpecificProps,
|
||||
openConversationInternal: action('openConversationInternal'),
|
||||
|
@ -102,12 +108,19 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
selectedConversationId: undefined,
|
||||
selectedMessageId: undefined,
|
||||
setComposeSearchTerm: action('setComposeSearchTerm'),
|
||||
setComposeGroupAvatar: action('setComposeGroupAvatar'),
|
||||
setComposeGroupName: action('setComposeGroupName'),
|
||||
showArchivedConversations: action('showArchivedConversations'),
|
||||
showInbox: action('showInbox'),
|
||||
startComposing: action('startComposing'),
|
||||
showChooseGroupMembers: action('showChooseGroupMembers'),
|
||||
startNewConversationFromPhoneNumber: action(
|
||||
'startNewConversationFromPhoneNumber'
|
||||
),
|
||||
startSettingGroupMetadata: action('startSettingGroupMetadata'),
|
||||
toggleConversationInChooseMembers: action(
|
||||
'toggleConversationInChooseMembers'
|
||||
),
|
||||
|
||||
...overrideProps,
|
||||
});
|
||||
|
|
|
@ -26,18 +26,29 @@ import {
|
|||
LeftPaneComposeHelper,
|
||||
LeftPaneComposePropsType,
|
||||
} from './leftPane/LeftPaneComposeHelper';
|
||||
import {
|
||||
LeftPaneChooseGroupMembersHelper,
|
||||
LeftPaneChooseGroupMembersPropsType,
|
||||
} from './leftPane/LeftPaneChooseGroupMembersHelper';
|
||||
import {
|
||||
LeftPaneSetGroupMetadataHelper,
|
||||
LeftPaneSetGroupMetadataPropsType,
|
||||
} from './leftPane/LeftPaneSetGroupMetadataHelper';
|
||||
|
||||
import * as OS from '../OS';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
import { ConversationList } from './ConversationList';
|
||||
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
||||
|
||||
export enum LeftPaneMode {
|
||||
Inbox,
|
||||
Search,
|
||||
Archive,
|
||||
Compose,
|
||||
ChooseGroupMembers,
|
||||
SetGroupMetadata,
|
||||
}
|
||||
|
||||
export type PropsType = {
|
||||
|
@ -56,23 +67,40 @@ export type PropsType = {
|
|||
} & LeftPaneArchivePropsType)
|
||||
| ({
|
||||
mode: LeftPaneMode.Compose;
|
||||
} & LeftPaneComposePropsType);
|
||||
} & LeftPaneComposePropsType)
|
||||
| ({
|
||||
mode: LeftPaneMode.ChooseGroupMembers;
|
||||
} & LeftPaneChooseGroupMembersPropsType)
|
||||
| ({
|
||||
mode: LeftPaneMode.SetGroupMetadata;
|
||||
} & LeftPaneSetGroupMetadataPropsType);
|
||||
i18n: LocalizerType;
|
||||
selectedConversationId: undefined | string;
|
||||
selectedMessageId: undefined | string;
|
||||
regionCode: string;
|
||||
|
||||
// Action Creators
|
||||
cantAddContactToGroup: (conversationId: string) => void;
|
||||
clearGroupCreationError: () => void;
|
||||
closeCantAddContactToGroupModal: () => void;
|
||||
closeMaximumGroupSizeModal: () => void;
|
||||
closeRecommendedGroupSizeModal: () => void;
|
||||
createGroup: () => void;
|
||||
startNewConversationFromPhoneNumber: (e164: string) => void;
|
||||
openConversationInternal: (_: {
|
||||
conversationId: string;
|
||||
messageId?: string;
|
||||
switchToAssociatedView?: boolean;
|
||||
}) => void;
|
||||
setComposeSearchTerm: (composeSearchTerm: string) => void;
|
||||
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => void;
|
||||
setComposeGroupName: (_: string) => void;
|
||||
showArchivedConversations: () => void;
|
||||
showInbox: () => void;
|
||||
startComposing: () => void;
|
||||
setComposeSearchTerm: (composeSearchTerm: string) => void;
|
||||
showChooseGroupMembers: () => void;
|
||||
startSettingGroupMetadata: () => void;
|
||||
toggleConversationInChooseMembers: (conversationId: string) => void;
|
||||
|
||||
// Render Props
|
||||
renderExpiredBuildDialog: () => JSX.Element;
|
||||
|
@ -84,6 +112,12 @@ export type PropsType = {
|
|||
};
|
||||
|
||||
export const LeftPane: React.FC<PropsType> = ({
|
||||
cantAddContactToGroup,
|
||||
clearGroupCreationError,
|
||||
closeCantAddContactToGroupModal,
|
||||
closeMaximumGroupSizeModal,
|
||||
closeRecommendedGroupSizeModal,
|
||||
createGroup,
|
||||
i18n,
|
||||
modeSpecificProps,
|
||||
openConversationInternal,
|
||||
|
@ -96,10 +130,15 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
selectedConversationId,
|
||||
selectedMessageId,
|
||||
setComposeSearchTerm,
|
||||
setComposeGroupAvatar,
|
||||
setComposeGroupName,
|
||||
showArchivedConversations,
|
||||
showInbox,
|
||||
startComposing,
|
||||
showChooseGroupMembers,
|
||||
startNewConversationFromPhoneNumber,
|
||||
startSettingGroupMetadata,
|
||||
toggleConversationInChooseMembers,
|
||||
}) => {
|
||||
const previousModeSpecificPropsRef = useRef(modeSpecificProps);
|
||||
const previousModeSpecificProps = previousModeSpecificPropsRef.current;
|
||||
|
@ -162,6 +201,32 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
helper = composeHelper;
|
||||
break;
|
||||
}
|
||||
case LeftPaneMode.ChooseGroupMembers: {
|
||||
const chooseGroupMembersHelper = new LeftPaneChooseGroupMembersHelper(
|
||||
modeSpecificProps
|
||||
);
|
||||
shouldRecomputeRowHeights =
|
||||
previousModeSpecificProps.mode === modeSpecificProps.mode
|
||||
? chooseGroupMembersHelper.shouldRecomputeRowHeights(
|
||||
previousModeSpecificProps
|
||||
)
|
||||
: true;
|
||||
helper = chooseGroupMembersHelper;
|
||||
break;
|
||||
}
|
||||
case LeftPaneMode.SetGroupMetadata: {
|
||||
const setGroupMetadataHelper = new LeftPaneSetGroupMetadataHelper(
|
||||
modeSpecificProps
|
||||
);
|
||||
shouldRecomputeRowHeights =
|
||||
previousModeSpecificProps.mode === modeSpecificProps.mode
|
||||
? setGroupMetadataHelper.shouldRecomputeRowHeights(
|
||||
previousModeSpecificProps
|
||||
)
|
||||
: true;
|
||||
helper = setGroupMetadataHelper;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(modeSpecificProps);
|
||||
}
|
||||
|
@ -245,11 +310,25 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
]);
|
||||
|
||||
const preRowsNode = helper.getPreRowsNode({
|
||||
clearGroupCreationError,
|
||||
closeCantAddContactToGroupModal,
|
||||
closeMaximumGroupSizeModal,
|
||||
closeRecommendedGroupSizeModal,
|
||||
createGroup,
|
||||
i18n,
|
||||
setComposeGroupAvatar,
|
||||
setComposeGroupName,
|
||||
onChangeComposeSearchTerm: event => {
|
||||
setComposeSearchTerm(event.target.value);
|
||||
},
|
||||
removeSelectedContact: toggleConversationInChooseMembers,
|
||||
});
|
||||
const footerContents = helper.getFooterContents({
|
||||
createGroup,
|
||||
i18n,
|
||||
startSettingGroupMetadata,
|
||||
});
|
||||
|
||||
const getRow = useMemo(() => helper.getRow.bind(helper), [helper]);
|
||||
|
||||
// We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring
|
||||
|
@ -261,7 +340,12 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
return (
|
||||
<div className="module-left-pane">
|
||||
<div className="module-left-pane__header">
|
||||
{helper.getHeaderContents({ i18n, showInbox }) || renderMainHeader()}
|
||||
{helper.getHeaderContents({
|
||||
i18n,
|
||||
showInbox,
|
||||
startComposing,
|
||||
showChooseGroupMembers,
|
||||
}) || renderMainHeader()}
|
||||
</div>
|
||||
{renderExpiredBuildDialog()}
|
||||
{renderRelinkDialog()}
|
||||
|
@ -288,6 +372,24 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
getRow={getRow}
|
||||
i18n={i18n}
|
||||
onClickArchiveButton={showArchivedConversations}
|
||||
onClickContactCheckbox={(
|
||||
conversationId: string,
|
||||
disabledReason: undefined | ContactCheckboxDisabledReason
|
||||
) => {
|
||||
switch (disabledReason) {
|
||||
case undefined:
|
||||
toggleConversationInChooseMembers(conversationId);
|
||||
break;
|
||||
case ContactCheckboxDisabledReason.MaximumContactsSelected:
|
||||
// This is a no-op.
|
||||
break;
|
||||
case ContactCheckboxDisabledReason.NotCapable:
|
||||
cantAddContactToGroup(conversationId);
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(disabledReason);
|
||||
}
|
||||
}}
|
||||
onSelectConversation={(
|
||||
conversationId: string,
|
||||
messageId?: string
|
||||
|
@ -304,6 +406,7 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
selectedConversationId
|
||||
)}
|
||||
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
|
||||
showChooseGroupMembers={showChooseGroupMembers}
|
||||
startNewConversationFromPhoneNumber={
|
||||
startNewConversationFromPhoneNumber
|
||||
}
|
||||
|
@ -313,6 +416,9 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
</div>
|
||||
)}
|
||||
</Measure>
|
||||
{footerContents && (
|
||||
<div className="module-left-pane__footer">{footerContents}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { NewlyCreatedGroupInvitedContactsDialog } from './NewlyCreatedGroupInvitedContactsDialog';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const conversations: Array<ConversationType> = [
|
||||
{
|
||||
id: 'fred-convo',
|
||||
isSelected: false,
|
||||
lastUpdated: Date.now(),
|
||||
markedUnread: false,
|
||||
title: 'Fred Willard',
|
||||
type: 'direct',
|
||||
},
|
||||
{
|
||||
id: 'marc-convo',
|
||||
isSelected: true,
|
||||
lastUpdated: Date.now(),
|
||||
markedUnread: false,
|
||||
title: 'Marc Barraca',
|
||||
type: 'direct',
|
||||
},
|
||||
];
|
||||
|
||||
const story = storiesOf(
|
||||
'Components/NewlyCreatedGroupInvitedContactsDialog',
|
||||
module
|
||||
);
|
||||
|
||||
story.add('One contact', () => (
|
||||
<NewlyCreatedGroupInvitedContactsDialog
|
||||
contacts={[conversations[0]]}
|
||||
i18n={i18n}
|
||||
onClose={action('onClose')}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Two contacts', () => (
|
||||
<NewlyCreatedGroupInvitedContactsDialog
|
||||
contacts={conversations}
|
||||
i18n={i18n}
|
||||
onClose={action('onClose')}
|
||||
/>
|
||||
));
|
80
ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx
Normal file
80
ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { FunctionComponent, ReactNode } from 'react';
|
||||
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { Intl } from './Intl';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
import { GroupDialog } from './GroupDialog';
|
||||
|
||||
type PropsType = {
|
||||
contacts: Array<ConversationType>;
|
||||
i18n: LocalizerType;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const NewlyCreatedGroupInvitedContactsDialog: FunctionComponent<PropsType> = ({
|
||||
contacts,
|
||||
i18n,
|
||||
onClose,
|
||||
}) => {
|
||||
let title: string;
|
||||
let body: ReactNode;
|
||||
if (contacts.length === 1) {
|
||||
const contact = contacts[0];
|
||||
|
||||
title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--one');
|
||||
body = (
|
||||
<>
|
||||
<GroupDialog.Paragraph>
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--one"
|
||||
components={[<ContactName i18n={i18n} title={contact.title} />]}
|
||||
/>
|
||||
</GroupDialog.Paragraph>
|
||||
<GroupDialog.Paragraph>
|
||||
{i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')}
|
||||
</GroupDialog.Paragraph>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--many', [
|
||||
contacts.length.toString(),
|
||||
]);
|
||||
body = (
|
||||
<>
|
||||
<GroupDialog.Paragraph>
|
||||
{i18n(
|
||||
'NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--many'
|
||||
)}
|
||||
</GroupDialog.Paragraph>
|
||||
<GroupDialog.Paragraph>
|
||||
{i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')}
|
||||
</GroupDialog.Paragraph>
|
||||
<GroupDialog.Contacts contacts={contacts} i18n={i18n} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupDialog
|
||||
i18n={i18n}
|
||||
onClickPrimaryButton={onClose}
|
||||
primaryButtonText={i18n('Confirmation--confirm')}
|
||||
secondaryButtonText={i18n(
|
||||
'NewlyCreatedGroupInvitedContactsDialog--body--learn-more'
|
||||
)}
|
||||
onClickSecondaryButton={() => {
|
||||
window.location.href =
|
||||
'https://support.signal.org/hc/articles/360007319331-Group-chats';
|
||||
}}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
>
|
||||
{body}
|
||||
</GroupDialog>
|
||||
);
|
||||
};
|
|
@ -7,20 +7,34 @@ import { LocalizerType } from '../../types/Util';
|
|||
import { Emojify } from './Emojify';
|
||||
|
||||
export type PropsType = {
|
||||
firstName?: string;
|
||||
i18n: LocalizerType;
|
||||
title: string;
|
||||
module?: string;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
preferFirstName?: boolean;
|
||||
profileName?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const ContactName = ({ module, title }: PropsType): JSX.Element => {
|
||||
export const ContactName = ({
|
||||
firstName,
|
||||
module,
|
||||
preferFirstName,
|
||||
title,
|
||||
}: PropsType): JSX.Element => {
|
||||
const prefix = module || 'module-contact-name';
|
||||
|
||||
let text: string;
|
||||
if (preferFirstName) {
|
||||
text = firstName || title || '';
|
||||
} else {
|
||||
text = title || '';
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={prefix} dir="auto">
|
||||
<Emojify text={title || ''} />
|
||||
<Emojify text={text} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,7 +7,6 @@ import { LocalizerType } from '../../types/Util';
|
|||
import { ConversationType } from '../../state/ducks/conversations';
|
||||
import { Intl } from '../Intl';
|
||||
import { ContactName } from './ContactName';
|
||||
import { ModalHost } from '../ModalHost';
|
||||
import { GroupV1MigrationDialog } from '../GroupV1MigrationDialog';
|
||||
|
||||
export type PropsDataType = {
|
||||
|
@ -58,19 +57,17 @@ export function GroupV1Migration(props: PropsType): React.ReactElement {
|
|||
{i18n('GroupV1--Migration--learn-more')}
|
||||
</button>
|
||||
{showingDialog ? (
|
||||
<ModalHost onClose={dismissDialog}>
|
||||
<GroupV1MigrationDialog
|
||||
areWeInvited={areWeInvited}
|
||||
droppedMembers={droppedMembers}
|
||||
hasMigrated
|
||||
i18n={i18n}
|
||||
invitedMembers={invitedMembers}
|
||||
migrate={() =>
|
||||
window.log.warn('GroupV1Migration: Modal called migrate()')
|
||||
}
|
||||
onClose={dismissDialog}
|
||||
/>
|
||||
</ModalHost>
|
||||
<GroupV1MigrationDialog
|
||||
areWeInvited={areWeInvited}
|
||||
droppedMembers={droppedMembers}
|
||||
hasMigrated
|
||||
i18n={i18n}
|
||||
invitedMembers={invitedMembers}
|
||||
migrate={() =>
|
||||
window.log.warn('GroupV1Migration: Modal called migrate()')
|
||||
}
|
||||
onClose={dismissDialog}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -211,6 +211,9 @@ const items: Record<string, TimelineItemType> = {
|
|||
|
||||
const actions = () => ({
|
||||
clearChangedMessages: action('clearChangedMessages'),
|
||||
clearInvitedConversationsForNewlyCreatedGroup: action(
|
||||
'clearInvitedConversationsForNewlyCreatedGroup'
|
||||
),
|
||||
setLoadCountdownStart: action('setLoadCountdownStart'),
|
||||
setIsNearBottom: action('setIsNearBottom'),
|
||||
loadAndScroll: action('loadAndScroll'),
|
||||
|
@ -299,6 +302,8 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
oldestUnreadIndex:
|
||||
number('oldestUnreadIndex', overrideProps.oldestUnreadIndex || 0) ||
|
||||
undefined,
|
||||
invitedContactsForNewlyCreatedGroup:
|
||||
overrideProps.invitedContactsForNewlyCreatedGroup || [],
|
||||
|
||||
id: '',
|
||||
renderItem,
|
||||
|
@ -361,3 +366,22 @@ story.add('Without Oldest Message', () => {
|
|||
|
||||
return <Timeline {...props} />;
|
||||
});
|
||||
|
||||
story.add('With invited contacts for a newly-created group', () => {
|
||||
const props = createProps({
|
||||
invitedContactsForNewlyCreatedGroup: [
|
||||
{
|
||||
id: 'abc123',
|
||||
title: 'John Bon Bon Jovi',
|
||||
type: 'direct',
|
||||
},
|
||||
{
|
||||
id: 'def456',
|
||||
title: 'Bon John Bon Jovi',
|
||||
type: 'direct',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return <Timeline {...props} />;
|
||||
});
|
||||
|
|
|
@ -15,9 +15,11 @@ import {
|
|||
import { ScrollDownButton } from './ScrollDownButton';
|
||||
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import { ConversationType } from '../../state/ducks/conversations';
|
||||
|
||||
import { PropsActions as MessageActionsType } from './Message';
|
||||
import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification';
|
||||
import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog';
|
||||
|
||||
const AT_BOTTOM_THRESHOLD = 15;
|
||||
const NEAR_BOTTOM_THRESHOLD = 15;
|
||||
|
@ -48,6 +50,7 @@ type PropsHousekeepingType = {
|
|||
isGroupV1AndDisabled?: boolean;
|
||||
|
||||
selectedMessageId?: string;
|
||||
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
|
||||
|
||||
i18n: LocalizerType;
|
||||
|
||||
|
@ -68,6 +71,7 @@ type PropsHousekeepingType = {
|
|||
|
||||
type PropsActionsType = {
|
||||
clearChangedMessages: (conversationId: string) => unknown;
|
||||
clearInvitedConversationsForNewlyCreatedGroup: () => void;
|
||||
setLoadCountdownStart: (
|
||||
conversationId: string,
|
||||
loadCountdownStart?: number
|
||||
|
@ -1063,7 +1067,14 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
};
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
const { i18n, id, items, isGroupV1AndDisabled } = this.props;
|
||||
const {
|
||||
clearInvitedConversationsForNewlyCreatedGroup,
|
||||
i18n,
|
||||
id,
|
||||
items,
|
||||
isGroupV1AndDisabled,
|
||||
invitedContactsForNewlyCreatedGroup,
|
||||
} = this.props;
|
||||
const {
|
||||
shouldShowScrollDownButton,
|
||||
areUnreadBelowCurrentPosition,
|
||||
|
@ -1077,60 +1088,70 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-timeline',
|
||||
isGroupV1AndDisabled ? 'module-timeline--disabled' : null
|
||||
)}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
if (this.mostRecentWidth && this.mostRecentWidth !== width) {
|
||||
this.resizeFlag = true;
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-timeline',
|
||||
isGroupV1AndDisabled ? 'module-timeline--disabled' : null
|
||||
)}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
if (this.mostRecentWidth && this.mostRecentWidth !== width) {
|
||||
this.resizeFlag = true;
|
||||
|
||||
setTimeout(this.resize, 0);
|
||||
} else if (
|
||||
this.mostRecentHeight &&
|
||||
this.mostRecentHeight !== height
|
||||
) {
|
||||
setTimeout(this.onHeightOnlyChange, 0);
|
||||
}
|
||||
setTimeout(this.resize, 0);
|
||||
} else if (
|
||||
this.mostRecentHeight &&
|
||||
this.mostRecentHeight !== height
|
||||
) {
|
||||
setTimeout(this.onHeightOnlyChange, 0);
|
||||
}
|
||||
|
||||
this.mostRecentWidth = width;
|
||||
this.mostRecentHeight = height;
|
||||
this.mostRecentWidth = width;
|
||||
this.mostRecentHeight = height;
|
||||
|
||||
return (
|
||||
<List
|
||||
deferredMeasurementCache={this.cellSizeCache}
|
||||
height={height}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onScroll={this.onScroll as any}
|
||||
overscanRowCount={10}
|
||||
ref={this.listRef}
|
||||
rowCount={rowCount}
|
||||
rowHeight={this.cellSizeCache.rowHeight}
|
||||
rowRenderer={this.rowRenderer}
|
||||
scrollToAlignment="start"
|
||||
scrollToIndex={scrollToIndex}
|
||||
tabIndex={-1}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
{shouldShowScrollDownButton ? (
|
||||
<ScrollDownButton
|
||||
conversationId={id}
|
||||
withNewMessages={areUnreadBelowCurrentPosition}
|
||||
scrollDown={this.onClickScrollDownButton}
|
||||
return (
|
||||
<List
|
||||
deferredMeasurementCache={this.cellSizeCache}
|
||||
height={height}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onScroll={this.onScroll as any}
|
||||
overscanRowCount={10}
|
||||
ref={this.listRef}
|
||||
rowCount={rowCount}
|
||||
rowHeight={this.cellSizeCache.rowHeight}
|
||||
rowRenderer={this.rowRenderer}
|
||||
scrollToAlignment="start"
|
||||
scrollToIndex={scrollToIndex}
|
||||
tabIndex={-1}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
{shouldShowScrollDownButton ? (
|
||||
<ScrollDownButton
|
||||
conversationId={id}
|
||||
withNewMessages={areUnreadBelowCurrentPosition}
|
||||
scrollDown={this.onClickScrollDownButton}
|
||||
i18n={i18n}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{Boolean(invitedContactsForNewlyCreatedGroup.length) && (
|
||||
<NewlyCreatedGroupInvitedContactsDialog
|
||||
contacts={invitedContactsForNewlyCreatedGroup}
|
||||
i18n={i18n}
|
||||
onClose={clearInvitedConversationsForNewlyCreatedGroup}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,11 +20,14 @@ export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`;
|
|||
const TIMESTAMP_CLASS_NAME = `${DATE_CLASS_NAME}__timestamp`;
|
||||
export const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`;
|
||||
export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
|
||||
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
|
||||
|
||||
type PropsType = {
|
||||
avatarPath?: string;
|
||||
checked?: boolean;
|
||||
color?: ColorType;
|
||||
conversationType: 'group' | 'direct';
|
||||
disabled?: boolean;
|
||||
headerDate?: number;
|
||||
headerName: ReactNode;
|
||||
i18n: LocalizerType;
|
||||
|
@ -37,7 +40,7 @@ type PropsType = {
|
|||
messageStatusIcon?: ReactNode;
|
||||
messageText?: ReactNode;
|
||||
name?: string;
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
style: CSSProperties;
|
||||
|
@ -48,8 +51,10 @@ type PropsType = {
|
|||
export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo(
|
||||
({
|
||||
avatarPath,
|
||||
checked,
|
||||
color,
|
||||
conversationType,
|
||||
disabled,
|
||||
headerDate,
|
||||
headerName,
|
||||
i18n,
|
||||
|
@ -74,17 +79,32 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
|
|||
? isNoteToSelf
|
||||
: Boolean(isMe);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
className={classNames(BASE_CLASS_NAME, {
|
||||
[`${BASE_CLASS_NAME}--has-unread`]: isUnread,
|
||||
[`${BASE_CLASS_NAME}--is-selected`]: isSelected,
|
||||
})}
|
||||
data-id={id ? cleanId(id) : undefined}
|
||||
>
|
||||
const isCheckbox = isBoolean(checked);
|
||||
|
||||
let checkboxNode: ReactNode;
|
||||
if (isCheckbox) {
|
||||
let ariaLabel: string;
|
||||
if (disabled) {
|
||||
ariaLabel = i18n('cannotSelectContact');
|
||||
} else if (checked) {
|
||||
ariaLabel = i18n('deselectContact');
|
||||
} else {
|
||||
ariaLabel = i18n('selectContact');
|
||||
}
|
||||
checkboxNode = (
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
checked={checked}
|
||||
className={CHECKBOX_CLASS_NAME}
|
||||
disabled={disabled}
|
||||
onChange={onClick}
|
||||
type="checkbox"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const contents = (
|
||||
<>
|
||||
<div className={`${BASE_CLASS_NAME}__avatar-container`}>
|
||||
<Avatar
|
||||
avatarPath={avatarPath}
|
||||
|
@ -104,7 +124,12 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={CONTENT_CLASS_NAME}>
|
||||
<div
|
||||
className={classNames(
|
||||
CONTENT_CLASS_NAME,
|
||||
disabled && `${CONTENT_CLASS_NAME}--disabled`
|
||||
)}
|
||||
>
|
||||
<div className={HEADER_CLASS_NAME}>
|
||||
<div className={`${HEADER_CLASS_NAME}__name`}>{headerName}</div>
|
||||
{isNumber(headerDate) && (
|
||||
|
@ -137,7 +162,61 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
{checkboxNode}
|
||||
</>
|
||||
);
|
||||
|
||||
const commonClassNames = classNames(BASE_CLASS_NAME, {
|
||||
[`${BASE_CLASS_NAME}--has-unread`]: isUnread,
|
||||
[`${BASE_CLASS_NAME}--is-selected`]: isSelected,
|
||||
});
|
||||
|
||||
if (isCheckbox) {
|
||||
return (
|
||||
<label
|
||||
className={classNames(
|
||||
commonClassNames,
|
||||
`${BASE_CLASS_NAME}--is-checkbox`,
|
||||
{ [`${BASE_CLASS_NAME}--is-checkbox--disabled`]: disabled }
|
||||
)}
|
||||
data-id={id ? cleanId(id) : undefined}
|
||||
style={style}
|
||||
// `onClick` is will double-fire if we're enabled. We want it to fire when we're
|
||||
// disabled so we can show any "can't add contact" modals, etc. This won't
|
||||
// work for keyboard users, though, because labels are not tabbable.
|
||||
{...(disabled ? { onClick } : {})}
|
||||
>
|
||||
{contents}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
commonClassNames,
|
||||
`${BASE_CLASS_NAME}--is-button`
|
||||
)}
|
||||
data-id={id ? cleanId(id) : undefined}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
type="button"
|
||||
>
|
||||
{contents}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={commonClassNames}
|
||||
data-id={id ? cleanId(id) : undefined}
|
||||
style={style}
|
||||
>
|
||||
{contents}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
97
ts/components/conversationList/ContactCheckbox.tsx
Normal file
97
ts/components/conversationList/ContactCheckbox.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { CSSProperties, FunctionComponent } from 'react';
|
||||
|
||||
import { BaseConversationListItem } from './BaseConversationListItem';
|
||||
import { ColorType } from '../../types/Colors';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import { ContactName } from '../conversation/ContactName';
|
||||
import { About } from '../conversation/About';
|
||||
|
||||
export enum ContactCheckboxDisabledReason {
|
||||
// We start the enum at 1 because the default starting value of 0 is falsy.
|
||||
MaximumContactsSelected = 1,
|
||||
NotCapable,
|
||||
}
|
||||
|
||||
export type PropsDataType = {
|
||||
about?: string;
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
disabledReason?: ContactCheckboxDisabledReason;
|
||||
id: string;
|
||||
isChecked: boolean;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type PropsHousekeepingType = {
|
||||
i18n: LocalizerType;
|
||||
style: CSSProperties;
|
||||
onClick: (
|
||||
id: string,
|
||||
disabledReason: undefined | ContactCheckboxDisabledReason
|
||||
) => void;
|
||||
};
|
||||
|
||||
type PropsType = PropsDataType & PropsHousekeepingType;
|
||||
|
||||
export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
|
||||
({
|
||||
about,
|
||||
avatarPath,
|
||||
color,
|
||||
disabledReason,
|
||||
i18n,
|
||||
id,
|
||||
isChecked,
|
||||
name,
|
||||
onClick,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
style,
|
||||
title,
|
||||
}) => {
|
||||
const disabled = Boolean(disabledReason);
|
||||
|
||||
const headerName = (
|
||||
<ContactName
|
||||
phoneNumber={phoneNumber}
|
||||
name={name}
|
||||
profileName={profileName}
|
||||
title={title}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
|
||||
const messageText = about ? <About className="" text={about} /> : null;
|
||||
|
||||
const onClickItem = () => {
|
||||
onClick(id, disabledReason);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseConversationListItem
|
||||
avatarPath={avatarPath}
|
||||
checked={isChecked}
|
||||
color={color}
|
||||
conversationType="direct"
|
||||
disabled={disabled}
|
||||
headerName={headerName}
|
||||
i18n={i18n}
|
||||
id={id}
|
||||
isSelected={false}
|
||||
messageText={messageText}
|
||||
name={name}
|
||||
onClick={onClickItem}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
style={style}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, CSSProperties, FunctionComponent } from 'react';
|
||||
import React, { CSSProperties, FunctionComponent } from 'react';
|
||||
|
||||
import { BaseConversationListItem } from './BaseConversationListItem';
|
||||
import { ColorType } from '../../types/Colors';
|
||||
|
@ -25,7 +25,7 @@ export type PropsDataType = {
|
|||
type PropsHousekeepingType = {
|
||||
i18n: LocalizerType;
|
||||
style: CSSProperties;
|
||||
onClick: (id: string) => void;
|
||||
onClick?: (id: string) => void;
|
||||
};
|
||||
|
||||
type PropsType = PropsDataType & PropsHousekeepingType;
|
||||
|
@ -61,8 +61,6 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
|
|||
const messageText =
|
||||
about && !isMe ? <About className="" text={about} /> : null;
|
||||
|
||||
const onClickItem = useCallback(() => onClick(id), [onClick, id]);
|
||||
|
||||
return (
|
||||
<BaseConversationListItem
|
||||
avatarPath={avatarPath}
|
||||
|
@ -75,7 +73,7 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
|
|||
isSelected={false}
|
||||
messageText={messageText}
|
||||
name={name}
|
||||
onClick={onClickItem}
|
||||
onClick={onClick ? () => onClick(id) : undefined}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
style={style}
|
||||
|
|
32
ts/components/conversationList/CreateNewGroupButton.tsx
Normal file
32
ts/components/conversationList/CreateNewGroupButton.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { CSSProperties, FunctionComponent } from 'react';
|
||||
|
||||
import { BaseConversationListItem } from './BaseConversationListItem';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
onClick: () => void;
|
||||
style: CSSProperties;
|
||||
};
|
||||
|
||||
export const CreateNewGroupButton: FunctionComponent<PropsType> = React.memo(
|
||||
({ i18n, onClick, style }) => {
|
||||
const title = i18n('createNewGroupButton');
|
||||
|
||||
return (
|
||||
<BaseConversationListItem
|
||||
color="grey"
|
||||
conversationType="group"
|
||||
headerName={title}
|
||||
i18n={i18n}
|
||||
isSelected={false}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
304
ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx
Normal file
304
ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx
Normal file
|
@ -0,0 +1,304 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { ReactChild, ChangeEvent } from 'react';
|
||||
|
||||
import { LeftPaneHelper } from './LeftPaneHelper';
|
||||
import { Row, RowType } from '../ConversationList';
|
||||
import { ConversationType } from '../../state/ducks/conversations';
|
||||
import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox';
|
||||
import { ContactPills } from '../ContactPills';
|
||||
import { ContactPill } from '../ContactPill';
|
||||
import { Alert } from '../Alert';
|
||||
import { Button } from '../Button';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import {
|
||||
getGroupSizeRecommendedLimit,
|
||||
getGroupSizeHardLimit,
|
||||
} from '../../groups/limits';
|
||||
|
||||
export type LeftPaneChooseGroupMembersPropsType = {
|
||||
candidateContacts: ReadonlyArray<ConversationType>;
|
||||
cantAddContactForModal: undefined | ConversationType;
|
||||
isShowingRecommendedGroupSizeModal: boolean;
|
||||
isShowingMaximumGroupSizeModal: boolean;
|
||||
searchTerm: string;
|
||||
selectedContacts: Array<ConversationType>;
|
||||
};
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<
|
||||
LeftPaneChooseGroupMembersPropsType
|
||||
> {
|
||||
private readonly candidateContacts: ReadonlyArray<ConversationType>;
|
||||
|
||||
private readonly cantAddContactForModal:
|
||||
| undefined
|
||||
| Readonly<{ title: string }>;
|
||||
|
||||
private readonly isShowingMaximumGroupSizeModal: boolean;
|
||||
|
||||
private readonly isShowingRecommendedGroupSizeModal: boolean;
|
||||
|
||||
private readonly searchTerm: string;
|
||||
|
||||
private readonly selectedContacts: Array<ConversationType>;
|
||||
|
||||
private readonly selectedConversationIdsSet: Set<string>;
|
||||
|
||||
constructor({
|
||||
candidateContacts,
|
||||
cantAddContactForModal,
|
||||
isShowingMaximumGroupSizeModal,
|
||||
isShowingRecommendedGroupSizeModal,
|
||||
searchTerm,
|
||||
selectedContacts,
|
||||
}: Readonly<LeftPaneChooseGroupMembersPropsType>) {
|
||||
super();
|
||||
|
||||
this.candidateContacts = candidateContacts;
|
||||
this.cantAddContactForModal = cantAddContactForModal;
|
||||
this.isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal;
|
||||
this.isShowingRecommendedGroupSizeModal = isShowingRecommendedGroupSizeModal;
|
||||
this.searchTerm = searchTerm;
|
||||
this.selectedContacts = selectedContacts;
|
||||
|
||||
this.selectedConversationIdsSet = new Set(
|
||||
selectedContacts.map(contact => contact.id)
|
||||
);
|
||||
}
|
||||
|
||||
getHeaderContents({
|
||||
i18n,
|
||||
startComposing,
|
||||
}: Readonly<{
|
||||
i18n: LocalizerType;
|
||||
startComposing: () => void;
|
||||
}>): ReactChild {
|
||||
const backButtonLabel = i18n('chooseGroupMembers__back-button');
|
||||
|
||||
return (
|
||||
<div className="module-left-pane__header__contents">
|
||||
<button
|
||||
aria-label={backButtonLabel}
|
||||
className="module-left-pane__header__contents__back-button"
|
||||
onClick={startComposing}
|
||||
title={backButtonLabel}
|
||||
type="button"
|
||||
/>
|
||||
<div className="module-left-pane__header__contents__text">
|
||||
{i18n('chooseGroupMembers__title')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getPreRowsNode({
|
||||
closeCantAddContactToGroupModal,
|
||||
closeMaximumGroupSizeModal,
|
||||
closeRecommendedGroupSizeModal,
|
||||
i18n,
|
||||
onChangeComposeSearchTerm,
|
||||
removeSelectedContact,
|
||||
}: Readonly<{
|
||||
closeCantAddContactToGroupModal: () => unknown;
|
||||
closeMaximumGroupSizeModal: () => unknown;
|
||||
closeRecommendedGroupSizeModal: () => unknown;
|
||||
i18n: LocalizerType;
|
||||
onChangeComposeSearchTerm: (
|
||||
event: ChangeEvent<HTMLInputElement>
|
||||
) => unknown;
|
||||
removeSelectedContact: (conversationId: string) => unknown;
|
||||
}>): ReactChild {
|
||||
let modalDetails:
|
||||
| undefined
|
||||
| { title: string; body: string; onClose: () => void };
|
||||
if (this.isShowingMaximumGroupSizeModal) {
|
||||
modalDetails = {
|
||||
title: i18n('chooseGroupMembers__maximum-group-size__title'),
|
||||
body: i18n('chooseGroupMembers__maximum-group-size__body', [
|
||||
this.getMaximumNumberOfContacts().toString(),
|
||||
]),
|
||||
onClose: closeMaximumGroupSizeModal,
|
||||
};
|
||||
} else if (this.isShowingRecommendedGroupSizeModal) {
|
||||
modalDetails = {
|
||||
title: i18n(
|
||||
'chooseGroupMembers__maximum-recommended-group-size__title'
|
||||
),
|
||||
body: i18n('chooseGroupMembers__maximum-recommended-group-size__body', [
|
||||
this.getRecommendedMaximumNumberOfContacts().toString(),
|
||||
]),
|
||||
onClose: closeRecommendedGroupSizeModal,
|
||||
};
|
||||
} else if (this.cantAddContactForModal) {
|
||||
modalDetails = {
|
||||
title: i18n('chooseGroupMembers__cant-add-member__title'),
|
||||
body: i18n('chooseGroupMembers__cant-add-member__body', [
|
||||
this.cantAddContactForModal.title,
|
||||
]),
|
||||
onClose: closeCantAddContactToGroupModal,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="module-left-pane__compose-search-form">
|
||||
<input
|
||||
type="text"
|
||||
ref={focusRef}
|
||||
className="module-left-pane__compose-search-form__input"
|
||||
placeholder={i18n('newConversationContactSearchPlaceholder')}
|
||||
dir="auto"
|
||||
value={this.searchTerm}
|
||||
onChange={onChangeComposeSearchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Boolean(this.selectedContacts.length) && (
|
||||
<ContactPills>
|
||||
{this.selectedContacts.map(contact => (
|
||||
<ContactPill
|
||||
key={contact.id}
|
||||
avatarPath={contact.avatarPath}
|
||||
color={contact.color}
|
||||
firstName={contact.firstName}
|
||||
i18n={i18n}
|
||||
id={contact.id}
|
||||
name={contact.name}
|
||||
phoneNumber={contact.phoneNumber}
|
||||
profileName={contact.profileName}
|
||||
title={contact.title}
|
||||
onClickRemove={removeSelectedContact}
|
||||
/>
|
||||
))}
|
||||
</ContactPills>
|
||||
)}
|
||||
|
||||
{this.getRowCount() ? null : (
|
||||
<div className="module-left-pane__compose-no-contacts">
|
||||
{i18n('newConversationNoContacts')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalDetails && (
|
||||
<Alert
|
||||
body={modalDetails.body}
|
||||
i18n={i18n}
|
||||
onClose={modalDetails.onClose}
|
||||
title={modalDetails.title}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
getFooterContents({
|
||||
i18n,
|
||||
startSettingGroupMetadata,
|
||||
}: Readonly<{
|
||||
i18n: LocalizerType;
|
||||
startSettingGroupMetadata: () => void;
|
||||
}>): ReactChild {
|
||||
return (
|
||||
<Button
|
||||
disabled={this.hasExceededMaximumNumberOfContacts()}
|
||||
onClick={startSettingGroupMetadata}
|
||||
>
|
||||
{this.selectedContacts.length
|
||||
? i18n('chooseGroupMembers__next')
|
||||
: i18n('chooseGroupMembers__skip')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
getRowCount(): number {
|
||||
if (!this.candidateContacts.length) {
|
||||
return 0;
|
||||
}
|
||||
return this.candidateContacts.length + 2;
|
||||
}
|
||||
|
||||
getRow(rowIndex: number): undefined | Row {
|
||||
if (!this.candidateContacts.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rowIndex === 0) {
|
||||
return {
|
||||
type: RowType.Header,
|
||||
i18nKey: 'contactsHeader',
|
||||
};
|
||||
}
|
||||
|
||||
// This puts a blank row for the footer.
|
||||
if (rowIndex === this.candidateContacts.length + 1) {
|
||||
return { type: RowType.Blank };
|
||||
}
|
||||
|
||||
const contact = this.candidateContacts[rowIndex - 1];
|
||||
if (!contact) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isChecked = this.selectedConversationIdsSet.has(contact.id);
|
||||
|
||||
let disabledReason: undefined | ContactCheckboxDisabledReason;
|
||||
if (!isChecked) {
|
||||
if (this.hasSelectedMaximumNumberOfContacts()) {
|
||||
disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected;
|
||||
} else if (!contact.isGroupV2Capable) {
|
||||
disabledReason = ContactCheckboxDisabledReason.NotCapable;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: RowType.ContactCheckbox,
|
||||
contact,
|
||||
isChecked,
|
||||
disabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
// This is deliberately unimplemented because these keyboard shortcuts shouldn't work in
|
||||
// the composer. The same is true for the "in direction" function below.
|
||||
getConversationAndMessageAtIndex(
|
||||
..._args: ReadonlyArray<unknown>
|
||||
): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getConversationAndMessageInDirection(
|
||||
..._args: ReadonlyArray<unknown>
|
||||
): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
shouldRecomputeRowHeights(_old: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
private hasSelectedMaximumNumberOfContacts(): boolean {
|
||||
return this.selectedContacts.length >= this.getMaximumNumberOfContacts();
|
||||
}
|
||||
|
||||
private hasExceededMaximumNumberOfContacts(): boolean {
|
||||
// It should be impossible to reach this state. This is here as a failsafe.
|
||||
return this.selectedContacts.length > this.getMaximumNumberOfContacts();
|
||||
}
|
||||
|
||||
private getRecommendedMaximumNumberOfContacts(): number {
|
||||
return getGroupSizeRecommendedLimit(151) - 1;
|
||||
}
|
||||
|
||||
private getMaximumNumberOfContacts(): number {
|
||||
return getGroupSizeHardLimit(1001) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
function focusRef(el: HTMLElement | null) {
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
}
|
|
@ -12,6 +12,9 @@ import {
|
|||
instance as phoneNumberInstance,
|
||||
PhoneNumberFormat,
|
||||
} from '../../util/libphonenumberInstance';
|
||||
import { assert } from '../../util/assert';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { isStorageWriteFeatureEnabled } from '../../storage/isFeatureEnabled';
|
||||
|
||||
export type LeftPaneComposePropsType = {
|
||||
composeContacts: ReadonlyArray<ContactListItemPropsType>;
|
||||
|
@ -19,6 +22,12 @@ export type LeftPaneComposePropsType = {
|
|||
searchTerm: string;
|
||||
};
|
||||
|
||||
enum TopButton {
|
||||
None,
|
||||
CreateNewGroup,
|
||||
StartNewConversation,
|
||||
}
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
export class LeftPaneComposeHelper extends LeftPaneHelper<
|
||||
|
@ -98,24 +107,53 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
|
|||
}
|
||||
|
||||
getRowCount(): number {
|
||||
return this.composeContacts.length + (this.phoneNumber ? 1 : 0);
|
||||
let result = this.composeContacts.length;
|
||||
if (this.hasTopButton()) {
|
||||
result += 1;
|
||||
}
|
||||
if (this.hasContactsHeader()) {
|
||||
result += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getRow(rowIndex: number): undefined | Row {
|
||||
let contactIndex = rowIndex;
|
||||
|
||||
if (this.phoneNumber) {
|
||||
if (rowIndex === 0) {
|
||||
return {
|
||||
type: RowType.StartNewConversation,
|
||||
phoneNumber: phoneNumberInstance.format(
|
||||
if (rowIndex === 0) {
|
||||
const topButton = this.getTopButton();
|
||||
switch (topButton) {
|
||||
case TopButton.None:
|
||||
break;
|
||||
case TopButton.StartNewConversation:
|
||||
assert(
|
||||
this.phoneNumber,
|
||||
PhoneNumberFormat.E164
|
||||
),
|
||||
};
|
||||
'LeftPaneComposeHelper: we should have a phone number if the top button is "Start new conversation"'
|
||||
);
|
||||
return {
|
||||
type: RowType.StartNewConversation,
|
||||
phoneNumber: phoneNumberInstance.format(
|
||||
this.phoneNumber,
|
||||
PhoneNumberFormat.E164
|
||||
),
|
||||
};
|
||||
case TopButton.CreateNewGroup:
|
||||
return { type: RowType.CreateNewGroup };
|
||||
default:
|
||||
throw missingCaseError(topButton);
|
||||
}
|
||||
}
|
||||
|
||||
contactIndex -= 1;
|
||||
if (rowIndex === 1 && this.hasContactsHeader()) {
|
||||
return {
|
||||
type: RowType.Header,
|
||||
i18nKey: 'contactsHeader',
|
||||
};
|
||||
}
|
||||
|
||||
let contactIndex: number;
|
||||
if (this.hasTopButton()) {
|
||||
contactIndex = rowIndex - 2;
|
||||
} else {
|
||||
contactIndex = rowIndex;
|
||||
}
|
||||
|
||||
const contact = this.composeContacts[contactIndex];
|
||||
|
@ -141,8 +179,29 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
|
|||
return undefined;
|
||||
}
|
||||
|
||||
shouldRecomputeRowHeights(_old: unknown): boolean {
|
||||
return false;
|
||||
shouldRecomputeRowHeights(old: Readonly<LeftPaneComposePropsType>): boolean {
|
||||
return (
|
||||
this.hasContactsHeader() !==
|
||||
new LeftPaneComposeHelper(old).hasContactsHeader()
|
||||
);
|
||||
}
|
||||
|
||||
private getTopButton(): TopButton {
|
||||
if (this.phoneNumber) {
|
||||
return TopButton.StartNewConversation;
|
||||
}
|
||||
if (this.searchTerm || !isStorageWriteFeatureEnabled()) {
|
||||
return TopButton.None;
|
||||
}
|
||||
return TopButton.CreateNewGroup;
|
||||
}
|
||||
|
||||
private hasTopButton(): boolean {
|
||||
return this.getTopButton() !== TopButton.None;
|
||||
}
|
||||
|
||||
private hasContactsHeader(): boolean {
|
||||
return this.hasTopButton() && Boolean(this.composeContacts.length);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,6 +23,8 @@ export abstract class LeftPaneHelper<T> {
|
|||
_: Readonly<{
|
||||
i18n: LocalizerType;
|
||||
showInbox: () => void;
|
||||
startComposing: () => void;
|
||||
showChooseGroupMembers: () => void;
|
||||
}>
|
||||
): null | ReactChild {
|
||||
return null;
|
||||
|
@ -34,10 +36,28 @@ export abstract class LeftPaneHelper<T> {
|
|||
|
||||
getPreRowsNode(
|
||||
_: Readonly<{
|
||||
clearGroupCreationError: () => void;
|
||||
closeCantAddContactToGroupModal: () => unknown;
|
||||
closeMaximumGroupSizeModal: () => unknown;
|
||||
closeRecommendedGroupSizeModal: () => unknown;
|
||||
createGroup: () => unknown;
|
||||
i18n: LocalizerType;
|
||||
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown;
|
||||
setComposeGroupName: (_: string) => unknown;
|
||||
onChangeComposeSearchTerm: (
|
||||
event: ChangeEvent<HTMLInputElement>
|
||||
) => unknown;
|
||||
removeSelectedContact: (_: string) => unknown;
|
||||
}>
|
||||
): null | ReactChild {
|
||||
return null;
|
||||
}
|
||||
|
||||
getFooterContents(
|
||||
_: Readonly<{
|
||||
i18n: LocalizerType;
|
||||
startSettingGroupMetadata: () => void;
|
||||
createGroup: () => unknown;
|
||||
}>
|
||||
): null | ReactChild {
|
||||
return null;
|
||||
|
|
218
ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx
Normal file
218
ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx
Normal file
|
@ -0,0 +1,218 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { ReactChild } from 'react';
|
||||
|
||||
import { LeftPaneHelper } from './LeftPaneHelper';
|
||||
import { Row, RowType } from '../ConversationList';
|
||||
import { PropsDataType as ContactListItemPropsType } from '../conversationList/ContactListItem';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import { AvatarInput } from '../AvatarInput';
|
||||
import { Alert } from '../Alert';
|
||||
import { Spinner } from '../Spinner';
|
||||
import { Button } from '../Button';
|
||||
|
||||
export type LeftPaneSetGroupMetadataPropsType = {
|
||||
groupAvatar: undefined | ArrayBuffer;
|
||||
groupName: string;
|
||||
hasError: boolean;
|
||||
isCreating: boolean;
|
||||
selectedContacts: ReadonlyArray<ContactListItemPropsType>;
|
||||
};
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<
|
||||
LeftPaneSetGroupMetadataPropsType
|
||||
> {
|
||||
private readonly groupAvatar: undefined | ArrayBuffer;
|
||||
|
||||
private readonly groupName: string;
|
||||
|
||||
private readonly hasError: boolean;
|
||||
|
||||
private readonly isCreating: boolean;
|
||||
|
||||
private readonly selectedContacts: ReadonlyArray<ContactListItemPropsType>;
|
||||
|
||||
constructor({
|
||||
groupAvatar,
|
||||
groupName,
|
||||
isCreating,
|
||||
hasError,
|
||||
selectedContacts,
|
||||
}: Readonly<LeftPaneSetGroupMetadataPropsType>) {
|
||||
super();
|
||||
|
||||
this.groupAvatar = groupAvatar;
|
||||
this.groupName = groupName;
|
||||
this.hasError = hasError;
|
||||
this.isCreating = isCreating;
|
||||
this.selectedContacts = selectedContacts;
|
||||
}
|
||||
|
||||
getHeaderContents({
|
||||
i18n,
|
||||
showChooseGroupMembers,
|
||||
}: Readonly<{
|
||||
i18n: LocalizerType;
|
||||
showChooseGroupMembers: () => void;
|
||||
}>): ReactChild {
|
||||
const backButtonLabel = i18n('setGroupMetadata__back-button');
|
||||
|
||||
return (
|
||||
<div className="module-left-pane__header__contents">
|
||||
<button
|
||||
aria-label={backButtonLabel}
|
||||
className="module-left-pane__header__contents__back-button"
|
||||
disabled={this.isCreating}
|
||||
onClick={showChooseGroupMembers}
|
||||
title={backButtonLabel}
|
||||
type="button"
|
||||
/>
|
||||
<div className="module-left-pane__header__contents__text">
|
||||
{i18n('setGroupMetadata__title')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getPreRowsNode({
|
||||
clearGroupCreationError,
|
||||
createGroup,
|
||||
i18n,
|
||||
setComposeGroupAvatar,
|
||||
setComposeGroupName,
|
||||
}: Readonly<{
|
||||
clearGroupCreationError: () => unknown;
|
||||
createGroup: () => unknown;
|
||||
i18n: LocalizerType;
|
||||
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown;
|
||||
setComposeGroupName: (_: string) => unknown;
|
||||
}>): ReactChild {
|
||||
const disabled = this.isCreating;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="module-left-pane__header__form"
|
||||
onSubmit={event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!this.canCreateGroup()) {
|
||||
return;
|
||||
}
|
||||
|
||||
createGroup();
|
||||
}}
|
||||
>
|
||||
<AvatarInput
|
||||
contextMenuId="left pane group avatar uploader"
|
||||
disabled={disabled}
|
||||
i18n={i18n}
|
||||
onChange={setComposeGroupAvatar}
|
||||
value={this.groupAvatar}
|
||||
/>
|
||||
<input
|
||||
disabled={disabled}
|
||||
className="module-left-pane__compose-input"
|
||||
onChange={event => {
|
||||
setComposeGroupName(event.target.value);
|
||||
}}
|
||||
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
|
||||
ref={focusRef}
|
||||
type="text"
|
||||
value={this.groupName}
|
||||
/>
|
||||
|
||||
{this.hasError && (
|
||||
<Alert
|
||||
body={i18n('setGroupMetadata__error-message')}
|
||||
i18n={i18n}
|
||||
onClose={clearGroupCreationError}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
getFooterContents({
|
||||
createGroup,
|
||||
i18n,
|
||||
}: Readonly<{
|
||||
createGroup: () => unknown;
|
||||
i18n: LocalizerType;
|
||||
}>): ReactChild {
|
||||
return (
|
||||
<Button disabled={!this.canCreateGroup()} onClick={createGroup}>
|
||||
{this.isCreating ? (
|
||||
<Spinner size="20px" svgSize="small" direction="on-avatar" />
|
||||
) : (
|
||||
i18n('setGroupMetadata__create-group')
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
getRowCount(): number {
|
||||
if (!this.selectedContacts.length) {
|
||||
return 0;
|
||||
}
|
||||
return this.selectedContacts.length + 2;
|
||||
}
|
||||
|
||||
getRow(rowIndex: number): undefined | Row {
|
||||
if (!this.selectedContacts.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rowIndex === 0) {
|
||||
return {
|
||||
type: RowType.Header,
|
||||
i18nKey: 'setGroupMetadata__members-header',
|
||||
};
|
||||
}
|
||||
|
||||
// This puts a blank row for the footer.
|
||||
if (rowIndex === this.selectedContacts.length + 1) {
|
||||
return { type: RowType.Blank };
|
||||
}
|
||||
|
||||
const contact = this.selectedContacts[rowIndex - 1];
|
||||
return contact
|
||||
? {
|
||||
type: RowType.Contact,
|
||||
contact,
|
||||
isClickable: false,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// This is deliberately unimplemented because these keyboard shortcuts shouldn't work in
|
||||
// the composer. The same is true for the "in direction" function below.
|
||||
getConversationAndMessageAtIndex(
|
||||
..._args: ReadonlyArray<unknown>
|
||||
): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getConversationAndMessageInDirection(
|
||||
..._args: ReadonlyArray<unknown>
|
||||
): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
shouldRecomputeRowHeights(_old: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
private canCreateGroup(): boolean {
|
||||
return !this.isCreating && Boolean(this.groupName.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function focusRef(el: HTMLElement | null) {
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
}
|
376
ts/groups.ts
376
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<UploadedAvatarType> {
|
||||
const { logId, publicParams, secretParams } = options;
|
||||
|
||||
try {
|
||||
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
|
||||
|
||||
const data = await window.Signal.Migrations.readAttachmentData(path);
|
||||
let data: ArrayBuffer;
|
||||
if ('data' in options) {
|
||||
({ data } = options);
|
||||
} else {
|
||||
data = await window.Signal.Migrations.readAttachmentData(options.path);
|
||||
}
|
||||
|
||||
const hash = await computeHash(data);
|
||||
|
||||
const blob = new window.textsecure.protobuf.GroupAttributeBlob();
|
||||
|
@ -350,13 +362,14 @@ async function uploadAvatar({
|
|||
logId: `uploadGroupAvatar/${logId}`,
|
||||
publicParams,
|
||||
secretParams,
|
||||
request: (sender, options) =>
|
||||
sender.uploadGroupAvatar(ciphertext, options),
|
||||
request: (sender, requestOptions) =>
|
||||
sender.uploadGroupAvatar(ciphertext, requestOptions),
|
||||
});
|
||||
|
||||
return {
|
||||
key,
|
||||
data,
|
||||
hash,
|
||||
key,
|
||||
};
|
||||
} catch (error) {
|
||||
window.log.warn(
|
||||
|
@ -367,11 +380,22 @@ async function uploadAvatar({
|
|||
}
|
||||
}
|
||||
|
||||
async function buildGroupProto({
|
||||
attributes,
|
||||
}: {
|
||||
attributes: ConversationAttributesType;
|
||||
}): Promise<GroupClass> {
|
||||
function buildGroupProto(
|
||||
attributes: Pick<
|
||||
ConversationAttributesType,
|
||||
| 'accessControl'
|
||||
| 'expireTimer'
|
||||
| 'id'
|
||||
| 'membersV2'
|
||||
| 'name'
|
||||
| 'pendingMembersV2'
|
||||
| 'publicParams'
|
||||
| 'revision'
|
||||
| 'secretParams'
|
||||
> & {
|
||||
avatarUrl?: string;
|
||||
}
|
||||
): GroupClass {
|
||||
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
|
||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||
const logId = `groupv2(${attributes.id})`;
|
||||
|
@ -404,21 +428,8 @@ async function buildGroupProto({
|
|||
const titleBlobPlaintext = titleBlob.toArrayBuffer();
|
||||
proto.title = encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext);
|
||||
|
||||
if (attributes.avatar && attributes.avatar.path) {
|
||||
const { path } = attributes.avatar;
|
||||
const { key, hash } = await uploadAvatar({
|
||||
logId,
|
||||
path,
|
||||
publicParams,
|
||||
secretParams,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
attributes.avatar.hash = hash;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
attributes.avatar.url = key;
|
||||
|
||||
proto.avatar = key;
|
||||
if (attributes.avatarUrl) {
|
||||
proto.avatar = attributes.avatarUrl;
|
||||
}
|
||||
|
||||
if (attributes.expireTimer) {
|
||||
|
@ -1159,6 +1170,237 @@ export async function fetchMembershipProof({
|
|||
return response.token;
|
||||
}
|
||||
|
||||
// Creating a group
|
||||
|
||||
export async function createGroupV2({
|
||||
name,
|
||||
avatar,
|
||||
conversationIds,
|
||||
}: Readonly<{
|
||||
name: string;
|
||||
avatar: undefined | ArrayBuffer;
|
||||
conversationIds: Array<string>;
|
||||
}>): Promise<ConversationModel> {
|
||||
// Ensure we have the credentials we need before attempting GroupsV2 operations
|
||||
await maybeFetchNewCredentials();
|
||||
|
||||
if (!isStorageWriteFeatureEnabled()) {
|
||||
throw new Error(
|
||||
'createGroupV2: storage service write is not enabled. Cannot create the group'
|
||||
);
|
||||
}
|
||||
|
||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
|
||||
|
||||
const masterKeyBuffer = getRandomBytes(32);
|
||||
const fields = deriveGroupFields(masterKeyBuffer);
|
||||
|
||||
const groupId = arrayBufferToBase64(fields.id);
|
||||
const logId = `groupv2(${groupId})`;
|
||||
|
||||
const masterKey = arrayBufferToBase64(masterKeyBuffer);
|
||||
const secretParams = arrayBufferToBase64(fields.secretParams);
|
||||
const publicParams = arrayBufferToBase64(fields.publicParams);
|
||||
|
||||
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
|
||||
const ourConversation = window.ConversationController.get(ourConversationId);
|
||||
if (!ourConversation) {
|
||||
throw new Error(
|
||||
`createGroupV2/${logId}: cannot get our own conversation. Cannot create the group`
|
||||
);
|
||||
}
|
||||
|
||||
const membersV2: Array<GroupV2MemberType> = [
|
||||
{
|
||||
conversationId: ourConversationId,
|
||||
role: MEMBER_ROLE_ENUM.ADMINISTRATOR,
|
||||
joinedAtVersion: 0,
|
||||
},
|
||||
];
|
||||
const pendingMembersV2: Array<GroupV2PendingMemberType> = [];
|
||||
|
||||
let uploadedAvatar: undefined | UploadedAvatarType;
|
||||
|
||||
await Promise.all([
|
||||
...conversationIds.map(async conversationId => {
|
||||
const contact = window.ConversationController.get(conversationId);
|
||||
if (!contact) {
|
||||
assert(
|
||||
false,
|
||||
`createGroupV2/${logId}: missing local contact, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contact.get('uuid')) {
|
||||
assert(false, `createGroupV2/${logId}: missing UUID; skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh our local data to be sure
|
||||
if (
|
||||
!contact.get('capabilities')?.gv2 ||
|
||||
!contact.get('profileKey') ||
|
||||
!contact.get('profileKeyCredential')
|
||||
) {
|
||||
await contact.getProfiles();
|
||||
}
|
||||
|
||||
if (!contact.get('capabilities')?.gv2) {
|
||||
assert(
|
||||
false,
|
||||
`createGroupV2/${logId}: member is missing GV2 capability; skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (contact.get('profileKey') && contact.get('profileKeyCredential')) {
|
||||
membersV2.push({
|
||||
conversationId,
|
||||
role: MEMBER_ROLE_ENUM.DEFAULT,
|
||||
joinedAtVersion: 0,
|
||||
});
|
||||
} else {
|
||||
pendingMembersV2.push({
|
||||
addedByUserId: ourConversationId,
|
||||
conversationId,
|
||||
timestamp: Date.now(),
|
||||
role: MEMBER_ROLE_ENUM.DEFAULT,
|
||||
});
|
||||
}
|
||||
}),
|
||||
(async () => {
|
||||
if (!avatar) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadedAvatar = await uploadAvatar({
|
||||
data: avatar,
|
||||
logId,
|
||||
publicParams,
|
||||
secretParams,
|
||||
});
|
||||
})(),
|
||||
]);
|
||||
|
||||
if (membersV2.length + pendingMembersV2.length > getGroupSizeHardLimit()) {
|
||||
throw new Error(
|
||||
`createGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const protoAndConversationAttributes = {
|
||||
name,
|
||||
|
||||
// Core GroupV2 info
|
||||
revision: 0,
|
||||
publicParams,
|
||||
secretParams,
|
||||
|
||||
// GroupV2 state
|
||||
accessControl: {
|
||||
attributes: ACCESS_ENUM.MEMBER,
|
||||
members: ACCESS_ENUM.MEMBER,
|
||||
addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
|
||||
},
|
||||
membersV2,
|
||||
pendingMembersV2,
|
||||
};
|
||||
|
||||
const groupProto = await buildGroupProto({
|
||||
id: groupId,
|
||||
avatarUrl: uploadedAvatar?.key,
|
||||
...protoAndConversationAttributes,
|
||||
});
|
||||
|
||||
await makeRequestWithTemporalRetry({
|
||||
logId: `createGroupV2/${logId}`,
|
||||
publicParams,
|
||||
secretParams,
|
||||
request: (sender, options) => sender.createGroup(groupProto, options),
|
||||
});
|
||||
|
||||
let avatarAttribute: ConversationAttributesType['avatar'];
|
||||
if (uploadedAvatar) {
|
||||
try {
|
||||
avatarAttribute = {
|
||||
url: uploadedAvatar.key,
|
||||
path: await window.Signal.Migrations.writeNewAttachmentData(
|
||||
uploadedAvatar.data
|
||||
),
|
||||
hash: uploadedAvatar.hash,
|
||||
};
|
||||
} catch (err) {
|
||||
window.log.warn(
|
||||
`createGroupV2/${logId}: avatar failed to save to disk. Continuing on`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const conversation = await window.ConversationController.getOrCreateAndWait(
|
||||
groupId,
|
||||
'group',
|
||||
{
|
||||
...protoAndConversationAttributes,
|
||||
active_at: now,
|
||||
addedBy: ourConversationId,
|
||||
avatar: avatarAttribute,
|
||||
groupVersion: 2,
|
||||
masterKey,
|
||||
profileSharing: true,
|
||||
timestamp: now,
|
||||
needsStorageServiceSync: true,
|
||||
}
|
||||
);
|
||||
|
||||
await conversation.queueJob(() => {
|
||||
window.Signal.Services.storageServiceUploadJob();
|
||||
});
|
||||
|
||||
const timestamp = Date.now();
|
||||
const profileKey = ourConversation.get('profileKey');
|
||||
|
||||
const groupV2Info = conversation.getGroupV2Info({
|
||||
includePendingMembers: true,
|
||||
});
|
||||
|
||||
await wrapWithSyncMessageSend({
|
||||
conversation,
|
||||
logId: `sendMessageToGroup/${logId}`,
|
||||
send: async sender =>
|
||||
sender.sendMessageToGroup({
|
||||
groupV2: groupV2Info,
|
||||
timestamp,
|
||||
profileKey: profileKey ? base64ToArrayBuffer(profileKey) : undefined,
|
||||
}),
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const createdTheGroupMessage: MessageAttributesType = {
|
||||
...generateBasicMessage(),
|
||||
type: 'group-v2-change',
|
||||
sourceUuid: conversation.ourUuid,
|
||||
conversationId: conversation.id,
|
||||
received_at: timestamp,
|
||||
sent_at: timestamp,
|
||||
groupV2Change: {
|
||||
from: ourConversationId,
|
||||
details: [{ type: 'create' }],
|
||||
},
|
||||
};
|
||||
await window.Signal.Data.saveMessages([createdTheGroupMessage], {
|
||||
forceSave: true,
|
||||
});
|
||||
const model = new window.Whisper.Message(createdTheGroupMessage);
|
||||
window.MessageController.register(model.id, model);
|
||||
conversation.trigger('newmessage', model);
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
// Migrating a group
|
||||
|
||||
export async function hasV1GroupBeenMigrated(
|
||||
|
@ -1451,6 +1693,8 @@ export async function initiateMigrationToGroupV2(
|
|||
// Ensure we have the credentials we need before attempting GroupsV2 operations
|
||||
await maybeFetchNewCredentials();
|
||||
|
||||
let ourProfileKey: undefined | string;
|
||||
|
||||
try {
|
||||
await conversation.queueJob(async () => {
|
||||
const ACCESS_ENUM =
|
||||
|
@ -1485,6 +1729,15 @@ export async function initiateMigrationToGroupV2(
|
|||
`initiateMigrationToGroupV2/${logId}: Couldn't fetch our own conversationId!`
|
||||
);
|
||||
}
|
||||
const ourConversation = window.ConversationController.get(
|
||||
ourConversationId
|
||||
);
|
||||
if (!ourConversation) {
|
||||
throw new Error(
|
||||
`initiateMigrationToGroupV2/${logId}: cannot get our own conversation. Cannot migrate`
|
||||
);
|
||||
}
|
||||
ourProfileKey = ourConversation.get('profileKey');
|
||||
|
||||
const {
|
||||
membersV2,
|
||||
|
@ -1493,33 +1746,37 @@ export async function initiateMigrationToGroupV2(
|
|||
previousGroupV1Members,
|
||||
} = await getGroupMigrationMembers(conversation);
|
||||
|
||||
const rawSizeLimit = window.Signal.RemoteConfig.getValue(
|
||||
'global.groupsv2.groupSizeHardLimit'
|
||||
);
|
||||
if (!rawSizeLimit) {
|
||||
throw new Error(
|
||||
`initiateMigrationToGroupV2/${logId}: Failed to fetch group size limit`
|
||||
);
|
||||
}
|
||||
const sizeLimit = parseInt(rawSizeLimit, 10);
|
||||
if (!isFinite(sizeLimit)) {
|
||||
throw new Error(
|
||||
`initiateMigrationToGroupV2/${logId}: Failed to parse group size limit`
|
||||
);
|
||||
}
|
||||
if (membersV2.length + pendingMembersV2.length > sizeLimit) {
|
||||
if (
|
||||
membersV2.length + pendingMembersV2.length >
|
||||
getGroupSizeHardLimit()
|
||||
) {
|
||||
throw new Error(
|
||||
`initiateMigrationToGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}`
|
||||
);
|
||||
}
|
||||
|
||||
// Note: A few group elements don't need to change here:
|
||||
// - avatar
|
||||
// - name
|
||||
// - expireTimer
|
||||
let avatarAttribute: ConversationAttributesType['avatar'];
|
||||
const avatarPath = conversation.attributes.avatar?.path;
|
||||
if (avatarPath) {
|
||||
const { hash, key } = await uploadAvatar({
|
||||
logId,
|
||||
publicParams,
|
||||
secretParams,
|
||||
path: avatarPath,
|
||||
});
|
||||
avatarAttribute = {
|
||||
url: key,
|
||||
path: avatarPath,
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
const newAttributes = {
|
||||
...conversation.attributes,
|
||||
avatar: avatarAttribute,
|
||||
|
||||
// Core GroupV2 info
|
||||
revision: 0,
|
||||
|
@ -1550,12 +1807,10 @@ export async function initiateMigrationToGroupV2(
|
|||
members: undefined,
|
||||
};
|
||||
|
||||
const groupProto = await buildGroupProto({ attributes: newAttributes });
|
||||
|
||||
// Capture the CDK key provided by the server when we uploade
|
||||
if (groupProto.avatar && newAttributes.avatar) {
|
||||
newAttributes.avatar.url = groupProto.avatar;
|
||||
}
|
||||
const groupProto = buildGroupProto({
|
||||
...newAttributes,
|
||||
avatarUrl: avatarAttribute?.url,
|
||||
});
|
||||
|
||||
try {
|
||||
await makeRequestWithTemporalRetry({
|
||||
|
@ -1621,7 +1876,6 @@ export async function initiateMigrationToGroupV2(
|
|||
// We've migrated the group, now we need to let all other group members know about it
|
||||
const logId = conversation.idForLogging();
|
||||
const timestamp = Date.now();
|
||||
const profileKey = conversation.get('profileKey');
|
||||
|
||||
await wrapWithSyncMessageSend({
|
||||
conversation,
|
||||
|
@ -1633,7 +1887,9 @@ export async function initiateMigrationToGroupV2(
|
|||
includePendingMembers: true,
|
||||
}),
|
||||
timestamp,
|
||||
profileKey: profileKey ? base64ToArrayBuffer(profileKey) : undefined,
|
||||
profileKey: ourProfileKey
|
||||
? base64ToArrayBuffer(ourProfileKey)
|
||||
: undefined,
|
||||
}),
|
||||
timestamp,
|
||||
});
|
||||
|
|
29
ts/groups/limits.ts
Normal file
29
ts/groups/limits.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { isNumber } from 'lodash';
|
||||
import { parseIntOrThrow } from '../util/parseIntOrThrow';
|
||||
import { getValue, ConfigKeyType } from '../RemoteConfig';
|
||||
|
||||
function makeGetter(configKey: ConfigKeyType): (fallback?: number) => number {
|
||||
return fallback => {
|
||||
try {
|
||||
return parseIntOrThrow(
|
||||
getValue(configKey),
|
||||
'Failed to parse group size limit'
|
||||
);
|
||||
} catch (err) {
|
||||
if (isNumber(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const getGroupSizeRecommendedLimit = makeGetter(
|
||||
'global.groupsv2.maxGroupSize'
|
||||
);
|
||||
export const getGroupSizeHardLimit = makeGetter(
|
||||
'global.groupsv2.groupSizeHardLimit'
|
||||
);
|
|
@ -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(),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { debounce, isNumber, partition } from 'lodash';
|
||||
|
@ -19,7 +19,6 @@ import {
|
|||
StorageManifestClass,
|
||||
StorageRecordClass,
|
||||
} from '../textsecure.d';
|
||||
import { isEnabled } from '../RemoteConfig';
|
||||
import {
|
||||
mergeAccountRecord,
|
||||
mergeContactRecord,
|
||||
|
@ -33,6 +32,7 @@ import {
|
|||
import { ConversationModel } from '../models/conversations';
|
||||
import { storageJobQueue } from '../util/JobQueue';
|
||||
import { sleep } from '../util/sleep';
|
||||
import { isStorageWriteFeatureEnabled } from '../storage/isFeatureEnabled';
|
||||
|
||||
const {
|
||||
eraseStorageServiceStateFromConversations,
|
||||
|
@ -882,7 +882,7 @@ async function processManifest(
|
|||
}
|
||||
|
||||
async function sync(): Promise<void> {
|
||||
if (!isEnabled('desktop.storage')) {
|
||||
if (!isStorageWriteFeatureEnabled()) {
|
||||
window.log.info(
|
||||
'storageService.sync: Not starting desktop.storage is falsey'
|
||||
);
|
||||
|
@ -946,16 +946,9 @@ async function sync(): Promise<void> {
|
|||
}
|
||||
|
||||
async function upload(): Promise<void> {
|
||||
if (!isEnabled('desktop.storage')) {
|
||||
if (!isStorageWriteFeatureEnabled()) {
|
||||
window.log.info(
|
||||
'storageService.upload: Not starting desktop.storage is falsey'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
if (!isEnabled('desktop.storageWrite2')) {
|
||||
window.log.info(
|
||||
'storageService.upload: Not starting desktop.storageWrite2 is falsey'
|
||||
'storageService.upload: Not starting because the feature is not enabled'
|
||||
);
|
||||
|
||||
return;
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from 'lodash';
|
||||
|
||||
import { StateType as RootStateType } from '../reducer';
|
||||
import * as groups from '../../groups';
|
||||
import { calling } from '../../services/calling';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import { assert } from '../../util/assert';
|
||||
|
@ -30,6 +31,10 @@ import {
|
|||
} from '../../components/conversation/conversation-details/PendingInvites';
|
||||
import { GroupV2Membership } from '../../components/conversation/conversation-details/ConversationDetailsMembershipList';
|
||||
import { MediaItemType } from '../../components/LightboxGallery';
|
||||
import {
|
||||
getGroupSizeRecommendedLimit,
|
||||
getGroupSizeHardLimit,
|
||||
} from '../../groups/limits';
|
||||
|
||||
// State
|
||||
|
||||
|
@ -70,6 +75,7 @@ export type ConversationType = {
|
|||
isArchived?: boolean;
|
||||
isBlocked?: boolean;
|
||||
isGroupV1AndDisabled?: boolean;
|
||||
isGroupV2Capable?: boolean;
|
||||
isPinned?: boolean;
|
||||
isUntrusted?: boolean;
|
||||
isVerified?: boolean;
|
||||
|
@ -220,8 +226,47 @@ export type PreJoinConversationType = {
|
|||
approvalRequired: boolean;
|
||||
};
|
||||
|
||||
export enum ComposerStep {
|
||||
StartDirectConversation,
|
||||
ChooseGroupMembers,
|
||||
SetGroupMetadata,
|
||||
}
|
||||
|
||||
export enum OneTimeModalState {
|
||||
NeverShown,
|
||||
Showing,
|
||||
Shown,
|
||||
}
|
||||
|
||||
type ComposerGroupCreationState = {
|
||||
groupAvatar: undefined | ArrayBuffer;
|
||||
groupName: string;
|
||||
maximumGroupSizeModalState: OneTimeModalState;
|
||||
recommendedGroupSizeModalState: OneTimeModalState;
|
||||
selectedConversationIds: Array<string>;
|
||||
};
|
||||
|
||||
type ComposerStateType =
|
||||
| {
|
||||
step: ComposerStep.StartDirectConversation;
|
||||
contactSearchTerm: string;
|
||||
}
|
||||
| ({
|
||||
step: ComposerStep.ChooseGroupMembers;
|
||||
contactSearchTerm: string;
|
||||
cantAddContactIdForModal: undefined | string;
|
||||
} & ComposerGroupCreationState)
|
||||
| ({
|
||||
step: ComposerStep.SetGroupMetadata;
|
||||
} & ComposerGroupCreationState &
|
||||
(
|
||||
| { isCreating: false; hasError: boolean }
|
||||
| { isCreating: true; hasError: false }
|
||||
));
|
||||
|
||||
export type ConversationsStateType = {
|
||||
preJoinConversation?: PreJoinConversationType;
|
||||
invitedConversationIdsForNewlyCreatedGroup?: Array<string>;
|
||||
conversationLookup: ConversationLookupType;
|
||||
conversationsByE164: ConversationLookupType;
|
||||
conversationsByUuid: ConversationLookupType;
|
||||
|
@ -232,9 +277,7 @@ export type ConversationsStateType = {
|
|||
selectedConversationTitle?: string;
|
||||
selectedConversationPanelDepth: number;
|
||||
showArchived: boolean;
|
||||
composer?: {
|
||||
contactSearchTerm: string;
|
||||
};
|
||||
composer?: ComposerStateType;
|
||||
|
||||
// Note: it's very important that both of these locations are always kept up to date
|
||||
messagesLookup: MessageLookupType;
|
||||
|
@ -268,6 +311,25 @@ export const getConversationCallMode = (
|
|||
|
||||
// Actions
|
||||
|
||||
type CantAddContactToGroupActionType = {
|
||||
type: 'CANT_ADD_CONTACT_TO_GROUP';
|
||||
payload: {
|
||||
conversationId: string;
|
||||
};
|
||||
};
|
||||
type ClearGroupCreationErrorActionType = { type: 'CLEAR_GROUP_CREATION_ERROR' };
|
||||
type ClearInvitedConversationsForNewlyCreatedGroupActionType = {
|
||||
type: 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP';
|
||||
};
|
||||
type CloseCantAddContactToGroupModalActionType = {
|
||||
type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL';
|
||||
};
|
||||
type CloseMaximumGroupSizeModalActionType = {
|
||||
type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL';
|
||||
};
|
||||
type CloseRecommendedGroupSizeModalActionType = {
|
||||
type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL';
|
||||
};
|
||||
type SetPreJoinConversationActionType = {
|
||||
type: 'SET_PRE_JOIN_CONVERSATION';
|
||||
payload: {
|
||||
|
@ -301,6 +363,18 @@ export type ConversationUnloadedActionType = {
|
|||
id: string;
|
||||
};
|
||||
};
|
||||
type CreateGroupPendingActionType = {
|
||||
type: 'CREATE_GROUP_PENDING';
|
||||
};
|
||||
type CreateGroupFulfilledActionType = {
|
||||
type: 'CREATE_GROUP_FULFILLED';
|
||||
payload: {
|
||||
invitedConversationIds: Array<string>;
|
||||
};
|
||||
};
|
||||
type CreateGroupRejectedActionType = {
|
||||
type: 'CREATE_GROUP_REJECTED';
|
||||
};
|
||||
export type RemoveAllConversationsActionType = {
|
||||
type: 'CONVERSATIONS_REMOVE_ALL';
|
||||
payload: null;
|
||||
|
@ -435,6 +509,14 @@ export type ShowArchivedConversationsActionType = {
|
|||
type: 'SHOW_ARCHIVED_CONVERSATIONS';
|
||||
payload: null;
|
||||
};
|
||||
type SetComposeGroupAvatarActionType = {
|
||||
type: 'SET_COMPOSE_GROUP_AVATAR';
|
||||
payload: { groupAvatar: undefined | ArrayBuffer };
|
||||
};
|
||||
type SetComposeGroupNameActionType = {
|
||||
type: 'SET_COMPOSE_GROUP_NAME';
|
||||
payload: { groupName: string };
|
||||
};
|
||||
type SetComposeSearchTermActionType = {
|
||||
type: 'SET_COMPOSE_SEARCH_TERM';
|
||||
payload: { contactSearchTerm: string };
|
||||
|
@ -449,19 +531,42 @@ type SetRecentMediaItemsActionType = {
|
|||
type StartComposingActionType = {
|
||||
type: 'START_COMPOSING';
|
||||
};
|
||||
type ShowChooseGroupMembersActionType = {
|
||||
type: 'SHOW_CHOOSE_GROUP_MEMBERS';
|
||||
};
|
||||
type StartSettingGroupMetadataActionType = {
|
||||
type: 'START_SETTING_GROUP_METADATA';
|
||||
};
|
||||
export type SwitchToAssociatedViewActionType = {
|
||||
type: 'SWITCH_TO_ASSOCIATED_VIEW';
|
||||
payload: { conversationId: string };
|
||||
};
|
||||
export type ToggleConversationInChooseMembersActionType = {
|
||||
type: 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS';
|
||||
payload: {
|
||||
conversationId: string;
|
||||
maxRecommendedGroupSize: number;
|
||||
maxGroupSize: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConversationActionType =
|
||||
| CantAddContactToGroupActionType
|
||||
| ClearChangedMessagesActionType
|
||||
| ClearGroupCreationErrorActionType
|
||||
| ClearInvitedConversationsForNewlyCreatedGroupActionType
|
||||
| ClearSelectedMessageActionType
|
||||
| ClearUnreadMetricsActionType
|
||||
| CloseCantAddContactToGroupModalActionType
|
||||
| CloseMaximumGroupSizeModalActionType
|
||||
| CloseRecommendedGroupSizeModalActionType
|
||||
| ConversationAddedActionType
|
||||
| ConversationChangedActionType
|
||||
| ConversationRemovedActionType
|
||||
| ConversationUnloadedActionType
|
||||
| CreateGroupFulfilledActionType
|
||||
| CreateGroupPendingActionType
|
||||
| CreateGroupRejectedActionType
|
||||
| MessageChangedActionType
|
||||
| MessageDeletedActionType
|
||||
| MessagesAddedActionType
|
||||
|
@ -473,6 +578,8 @@ export type ConversationActionType =
|
|||
| RepairOldestMessageActionType
|
||||
| ScrollToMessageActionType
|
||||
| SelectedConversationChangedActionType
|
||||
| SetComposeGroupAvatarActionType
|
||||
| SetComposeGroupNameActionType
|
||||
| SetComposeSearchTermActionType
|
||||
| SetConversationHeaderTitleActionType
|
||||
| SetIsNearBottomActionType
|
||||
|
@ -484,18 +591,28 @@ export type ConversationActionType =
|
|||
| ShowArchivedConversationsActionType
|
||||
| ShowInboxActionType
|
||||
| StartComposingActionType
|
||||
| SwitchToAssociatedViewActionType;
|
||||
| ShowChooseGroupMembersActionType
|
||||
| StartSettingGroupMetadataActionType
|
||||
| SwitchToAssociatedViewActionType
|
||||
| ToggleConversationInChooseMembersActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
export const actions = {
|
||||
cantAddContactToGroup,
|
||||
clearChangedMessages,
|
||||
clearInvitedConversationsForNewlyCreatedGroup,
|
||||
clearGroupCreationError,
|
||||
clearSelectedMessage,
|
||||
clearUnreadMetrics,
|
||||
closeCantAddContactToGroupModal,
|
||||
closeRecommendedGroupSizeModal,
|
||||
closeMaximumGroupSizeModal,
|
||||
conversationAdded,
|
||||
conversationChanged,
|
||||
conversationRemoved,
|
||||
conversationUnloaded,
|
||||
createGroup,
|
||||
messageChanged,
|
||||
messageDeleted,
|
||||
messagesAdded,
|
||||
|
@ -508,6 +625,8 @@ export const actions = {
|
|||
repairOldestMessage,
|
||||
scrollToMessage,
|
||||
selectMessage,
|
||||
setComposeGroupAvatar,
|
||||
setComposeGroupName,
|
||||
setComposeSearchTerm,
|
||||
setIsNearBottom,
|
||||
setLoadCountdownStart,
|
||||
|
@ -519,9 +638,20 @@ export const actions = {
|
|||
showArchivedConversations,
|
||||
showInbox,
|
||||
startComposing,
|
||||
showChooseGroupMembers,
|
||||
startNewConversationFromPhoneNumber,
|
||||
startSettingGroupMetadata,
|
||||
toggleConversationInChooseMembers,
|
||||
};
|
||||
|
||||
function cantAddContactToGroup(
|
||||
conversationId: string
|
||||
): CantAddContactToGroupActionType {
|
||||
return {
|
||||
type: 'CANT_ADD_CONTACT_TO_GROUP',
|
||||
payload: { conversationId },
|
||||
};
|
||||
}
|
||||
function setPreJoinConversation(
|
||||
data: PreJoinConversationType | undefined
|
||||
): SetPreJoinConversationActionType {
|
||||
|
@ -576,6 +706,52 @@ function conversationUnloaded(id: string): ConversationUnloadedActionType {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createGroup(): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
| CreateGroupPendingActionType
|
||||
| CreateGroupFulfilledActionType
|
||||
| CreateGroupRejectedActionType
|
||||
| SwitchToAssociatedViewActionType
|
||||
> {
|
||||
return async (dispatch, getState, ...args) => {
|
||||
const { composer } = getState().conversations;
|
||||
if (
|
||||
composer?.step !== ComposerStep.SetGroupMetadata ||
|
||||
composer.isCreating
|
||||
) {
|
||||
assert(false, 'Cannot create group in this stage; doing nothing');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'CREATE_GROUP_PENDING' });
|
||||
|
||||
try {
|
||||
const conversation = await groups.createGroupV2({
|
||||
name: composer.groupName,
|
||||
avatar: composer.groupAvatar,
|
||||
conversationIds: composer.selectedConversationIds,
|
||||
});
|
||||
dispatch({
|
||||
type: 'CREATE_GROUP_FULFILLED',
|
||||
payload: {
|
||||
invitedConversationIds: (
|
||||
conversation.get('pendingMembersV2') || []
|
||||
).map(member => member.conversationId),
|
||||
},
|
||||
});
|
||||
openConversationInternal({
|
||||
conversationId: conversation.id,
|
||||
switchToAssociatedView: true,
|
||||
})(dispatch, getState, ...args);
|
||||
} catch (err) {
|
||||
dispatch({ type: 'CREATE_GROUP_REJECTED' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function removeAllConversations(): RemoveAllConversationsActionType {
|
||||
return {
|
||||
type: 'CONVERSATIONS_REMOVE_ALL',
|
||||
|
@ -761,6 +937,12 @@ function clearChangedMessages(
|
|||
},
|
||||
};
|
||||
}
|
||||
function clearInvitedConversationsForNewlyCreatedGroup(): ClearInvitedConversationsForNewlyCreatedGroupActionType {
|
||||
return { type: 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP' };
|
||||
}
|
||||
function clearGroupCreationError(): ClearGroupCreationErrorActionType {
|
||||
return { type: 'CLEAR_GROUP_CREATION_ERROR' };
|
||||
}
|
||||
function clearSelectedMessage(): ClearSelectedMessageActionType {
|
||||
return {
|
||||
type: 'CLEAR_SELECTED_MESSAGE',
|
||||
|
@ -777,7 +959,15 @@ function clearUnreadMetrics(
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
function closeCantAddContactToGroupModal(): CloseCantAddContactToGroupModalActionType {
|
||||
return { type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL' };
|
||||
}
|
||||
function closeMaximumGroupSizeModal(): CloseMaximumGroupSizeModalActionType {
|
||||
return { type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL' };
|
||||
}
|
||||
function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionType {
|
||||
return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' };
|
||||
}
|
||||
function scrollToMessage(
|
||||
conversationId: string,
|
||||
messageId: string
|
||||
|
@ -791,6 +981,22 @@ function scrollToMessage(
|
|||
};
|
||||
}
|
||||
|
||||
function setComposeGroupAvatar(
|
||||
groupAvatar: undefined | ArrayBuffer
|
||||
): SetComposeGroupAvatarActionType {
|
||||
return {
|
||||
type: 'SET_COMPOSE_GROUP_AVATAR',
|
||||
payload: { groupAvatar },
|
||||
};
|
||||
}
|
||||
|
||||
function setComposeGroupName(groupName: string): SetComposeGroupNameActionType {
|
||||
return {
|
||||
type: 'SET_COMPOSE_GROUP_NAME',
|
||||
payload: { groupName },
|
||||
};
|
||||
}
|
||||
|
||||
function setComposeSearchTerm(
|
||||
contactSearchTerm: string
|
||||
): SetComposeSearchTermActionType {
|
||||
|
@ -804,6 +1010,10 @@ function startComposing(): StartComposingActionType {
|
|||
return { type: 'START_COMPOSING' };
|
||||
}
|
||||
|
||||
function showChooseGroupMembers(): ShowChooseGroupMembersActionType {
|
||||
return { type: 'SHOW_CHOOSE_GROUP_MEMBERS' };
|
||||
}
|
||||
|
||||
function startNewConversationFromPhoneNumber(
|
||||
e164: string
|
||||
): ThunkAction<void, RootStateType, unknown, ShowInboxActionType> {
|
||||
|
@ -814,6 +1024,37 @@ function startNewConversationFromPhoneNumber(
|
|||
};
|
||||
}
|
||||
|
||||
function startSettingGroupMetadata(): StartSettingGroupMetadataActionType {
|
||||
return { type: 'START_SETTING_GROUP_METADATA' };
|
||||
}
|
||||
|
||||
function toggleConversationInChooseMembers(
|
||||
conversationId: string
|
||||
): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
ToggleConversationInChooseMembersActionType
|
||||
> {
|
||||
return dispatch => {
|
||||
const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
|
||||
const maxGroupSize = Math.max(
|
||||
getGroupSizeHardLimit(1001),
|
||||
maxRecommendedGroupSize + 1
|
||||
);
|
||||
|
||||
assert(
|
||||
maxGroupSize > maxRecommendedGroupSize,
|
||||
'Expected the hard max group size to be larger than the recommended maximum'
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS',
|
||||
payload: { conversationId, maxGroupSize, maxRecommendedGroupSize },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Note: we need two actions here to simplify. Operations outside of the left pane can
|
||||
// trigger an 'openConversation' so we go through Whisper.events for all
|
||||
// conversation selection. Internal just triggers the Whisper.event, and External
|
||||
|
@ -1007,10 +1248,94 @@ export function updateConversationLookups(
|
|||
return result;
|
||||
}
|
||||
|
||||
function closeComposerModal(
|
||||
state: Readonly<ConversationsStateType>,
|
||||
modalToClose: 'maximumGroupSizeModalState' | 'recommendedGroupSizeModalState'
|
||||
): ConversationsStateType {
|
||||
const { composer } = state;
|
||||
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
|
||||
assert(false, "Can't close the modal in this composer step. Doing nothing");
|
||||
return state;
|
||||
}
|
||||
if (composer[modalToClose] !== OneTimeModalState.Showing) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
composer: {
|
||||
...composer,
|
||||
[modalToClose]: OneTimeModalState.Shown,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: Readonly<ConversationsStateType> = getEmptyState(),
|
||||
action: Readonly<ConversationActionType>
|
||||
): ConversationsStateType {
|
||||
if (action.type === 'CANT_ADD_CONTACT_TO_GROUP') {
|
||||
const { composer } = state;
|
||||
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
|
||||
assert(false, "Can't update modal in this composer step. Doing nothing");
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
composer: {
|
||||
...composer,
|
||||
cantAddContactIdForModal: action.payload.conversationId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP') {
|
||||
return omit(state, 'invitedConversationIdsForNewlyCreatedGroup');
|
||||
}
|
||||
|
||||
if (action.type === 'CLEAR_GROUP_CREATION_ERROR') {
|
||||
const { composer } = state;
|
||||
if (composer?.step !== ComposerStep.SetGroupMetadata) {
|
||||
assert(
|
||||
false,
|
||||
"Can't clear group creation error in this composer state. Doing nothing"
|
||||
);
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
composer: {
|
||||
...composer,
|
||||
hasError: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL') {
|
||||
const { composer } = state;
|
||||
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
|
||||
assert(
|
||||
false,
|
||||
"Can't close the modal in this composer step. Doing nothing"
|
||||
);
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
composer: {
|
||||
...composer,
|
||||
cantAddContactIdForModal: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL') {
|
||||
return closeComposerModal(state, 'maximumGroupSizeModalState' as const);
|
||||
}
|
||||
|
||||
if (action.type === 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL') {
|
||||
return closeComposerModal(state, 'recommendedGroupSizeModalState' as const);
|
||||
}
|
||||
|
||||
if (action.type === 'SET_PRE_JOIN_CONVERSATION') {
|
||||
const { payload } = action;
|
||||
const { data } = payload;
|
||||
|
@ -1114,6 +1439,47 @@ export function reducer(
|
|||
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
|
||||
return getEmptyState();
|
||||
}
|
||||
if (action.type === 'CREATE_GROUP_PENDING') {
|
||||
const { composer } = state;
|
||||
if (composer?.step !== ComposerStep.SetGroupMetadata) {
|
||||
// This should be unlikely, but it can happen if someone closes the composer while
|
||||
// a group is being created.
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
composer: {
|
||||
...composer,
|
||||
hasError: false,
|
||||
isCreating: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (action.type === 'CREATE_GROUP_FULFILLED') {
|
||||
// We don't do much here and instead rely on `openConversationInternal` to do most of
|
||||
// the work.
|
||||
return {
|
||||
...state,
|
||||
invitedConversationIdsForNewlyCreatedGroup:
|
||||
action.payload.invitedConversationIds,
|
||||
};
|
||||
}
|
||||
if (action.type === 'CREATE_GROUP_REJECTED') {
|
||||
const { composer } = state;
|
||||
if (composer?.step !== ComposerStep.SetGroupMetadata) {
|
||||
// This should be unlikely, but it can happen if someone closes the composer while
|
||||
// a group is being created.
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
composer: {
|
||||
...composer,
|
||||
hasError: true,
|
||||
isCreating: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (action.type === 'SET_SELECTED_CONVERSATION_PANEL_DEPTH') {
|
||||
return {
|
||||
...state,
|
||||
|
@ -1728,7 +2094,7 @@ export function reducer(
|
|||
}
|
||||
|
||||
if (action.type === 'START_COMPOSING') {
|
||||
if (state.composer) {
|
||||
if (state.composer?.step === ComposerStep.StartDirectConversation) {
|
||||
return state;
|
||||
}
|
||||
|
||||
|
@ -1736,11 +2102,125 @@ export function reducer(
|
|||
...state,
|
||||
showArchived: false,
|
||||
composer: {
|
||||
step: ComposerStep.StartDirectConversation,
|
||||
contactSearchTerm: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'SHOW_CHOOSE_GROUP_MEMBERS') {
|
||||
let selectedConversationIds: Array<string>;
|
||||
let recommendedGroupSizeModalState: OneTimeModalState;
|
||||
let maximumGroupSizeModalState: OneTimeModalState;
|
||||
let groupName: string;
|
||||
let groupAvatar: undefined | ArrayBuffer;
|
||||
|
||||
switch (state.composer?.step) {
|
||||
case ComposerStep.ChooseGroupMembers:
|
||||
return state;
|
||||
case ComposerStep.SetGroupMetadata:
|
||||
({
|
||||
selectedConversationIds,
|
||||
recommendedGroupSizeModalState,
|
||||
maximumGroupSizeModalState,
|
||||
groupName,
|
||||
groupAvatar,
|
||||
} = state.composer);
|
||||
break;
|
||||
default:
|
||||
selectedConversationIds = [];
|
||||
recommendedGroupSizeModalState = OneTimeModalState.NeverShown;
|
||||
maximumGroupSizeModalState = OneTimeModalState.NeverShown;
|
||||
groupName = '';
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
showArchived: false,
|
||||
composer: {
|
||||
step: ComposerStep.ChooseGroupMembers,
|
||||
contactSearchTerm: '',
|
||||
selectedConversationIds,
|
||||
cantAddContactIdForModal: undefined,
|
||||
recommendedGroupSizeModalState,
|
||||
maximumGroupSizeModalState,
|
||||
groupName,
|
||||
groupAvatar,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'START_SETTING_GROUP_METADATA') {
|
||||
const { composer } = state;
|
||||
|
||||
switch (composer?.step) {
|
||||
case ComposerStep.ChooseGroupMembers:
|
||||
return {
|
||||
...state,
|
||||
showArchived: false,
|
||||
composer: {
|
||||
step: ComposerStep.SetGroupMetadata,
|
||||
isCreating: false,
|
||||
hasError: false,
|
||||
...pick(composer, [
|
||||
'groupAvatar',
|
||||
'groupName',
|
||||
'maximumGroupSizeModalState',
|
||||
'recommendedGroupSizeModalState',
|
||||
'selectedConversationIds',
|
||||
]),
|
||||
},
|
||||
};
|
||||
case ComposerStep.SetGroupMetadata:
|
||||
return state;
|
||||
default:
|
||||
assert(
|
||||
false,
|
||||
'Cannot transition to setting group metadata from this state'
|
||||
);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === 'SET_COMPOSE_GROUP_AVATAR') {
|
||||
const { composer } = state;
|
||||
|
||||
switch (composer?.step) {
|
||||
case ComposerStep.ChooseGroupMembers:
|
||||
case ComposerStep.SetGroupMetadata:
|
||||
return {
|
||||
...state,
|
||||
composer: {
|
||||
...composer,
|
||||
groupAvatar: action.payload.groupAvatar,
|
||||
},
|
||||
};
|
||||
default:
|
||||
assert(false, 'Setting compose group avatar at this step is a no-op');
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === 'SET_COMPOSE_GROUP_NAME') {
|
||||
const { composer } = state;
|
||||
|
||||
switch (composer?.step) {
|
||||
case ComposerStep.ChooseGroupMembers:
|
||||
case ComposerStep.SetGroupMetadata:
|
||||
return {
|
||||
...state,
|
||||
composer: {
|
||||
...composer,
|
||||
groupName: action.payload.groupName,
|
||||
},
|
||||
};
|
||||
default:
|
||||
assert(false, 'Setting compose group name at this step is a no-op');
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === 'SET_COMPOSE_SEARCH_TERM') {
|
||||
const { composer } = state;
|
||||
if (!composer) {
|
||||
|
@ -1750,6 +2230,10 @@ export function reducer(
|
|||
);
|
||||
return state;
|
||||
}
|
||||
if (composer?.step === ComposerStep.SetGroupMetadata) {
|
||||
assert(false, 'Setting compose search term at this step is a no-op');
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
@ -1774,5 +2258,63 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS') {
|
||||
const { composer } = state;
|
||||
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
|
||||
assert(
|
||||
false,
|
||||
'Toggling conversation members is a no-op in this composer step'
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
const { selectedConversationIds: oldSelectedConversationIds } = composer;
|
||||
let {
|
||||
maximumGroupSizeModalState,
|
||||
recommendedGroupSizeModalState,
|
||||
} = composer;
|
||||
const {
|
||||
conversationId,
|
||||
maxGroupSize,
|
||||
maxRecommendedGroupSize,
|
||||
} = action.payload;
|
||||
|
||||
const selectedConversationIds = without(
|
||||
oldSelectedConversationIds,
|
||||
conversationId
|
||||
);
|
||||
const shouldAdd =
|
||||
selectedConversationIds.length === oldSelectedConversationIds.length;
|
||||
if (shouldAdd) {
|
||||
// 1 for you, 1 for the new contact.
|
||||
const newExpectedMemberCount = selectedConversationIds.length + 2;
|
||||
if (newExpectedMemberCount > maxGroupSize) {
|
||||
return state;
|
||||
}
|
||||
if (
|
||||
newExpectedMemberCount === maxGroupSize &&
|
||||
maximumGroupSizeModalState === OneTimeModalState.NeverShown
|
||||
) {
|
||||
maximumGroupSizeModalState = OneTimeModalState.Showing;
|
||||
} else if (
|
||||
newExpectedMemberCount >= maxRecommendedGroupSize &&
|
||||
recommendedGroupSizeModalState === OneTimeModalState.NeverShown
|
||||
) {
|
||||
recommendedGroupSizeModalState = OneTimeModalState.Showing;
|
||||
}
|
||||
selectedConversationIds.push(conversationId);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
composer: {
|
||||
...composer,
|
||||
maximumGroupSizeModalState,
|
||||
recommendedGroupSizeModalState,
|
||||
selectedConversationIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import Fuse, { FuseOptions } from 'fuse.js';
|
|||
|
||||
import { StateType } from '../reducer';
|
||||
import {
|
||||
ComposerStep,
|
||||
ConversationLookupType,
|
||||
ConversationMessageType,
|
||||
ConversationsStateType,
|
||||
|
@ -15,10 +16,12 @@ import {
|
|||
MessageLookupType,
|
||||
MessagesByConversationType,
|
||||
MessageType,
|
||||
OneTimeModalState,
|
||||
PreJoinConversationType,
|
||||
} from '../ducks/conversations';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import { deconstructLookup } from '../../util/deconstructLookup';
|
||||
import type { CallsByConversationType } from '../ducks/calling';
|
||||
import { getCallsByConversation } from './calling';
|
||||
import { getBubbleProps } from '../../shims/Whisper';
|
||||
|
@ -143,9 +146,26 @@ const getComposerState = createSelector(
|
|||
(state: ConversationsStateType) => state.composer
|
||||
);
|
||||
|
||||
export const isComposing = createSelector(
|
||||
export const getComposerStep = createSelector(
|
||||
getComposerState,
|
||||
(composerState): boolean => Boolean(composerState)
|
||||
(composerState): undefined | ComposerStep => composerState?.step
|
||||
);
|
||||
|
||||
export const hasGroupCreationError = createSelector(
|
||||
getComposerState,
|
||||
(composerState): boolean => {
|
||||
if (composerState?.step === ComposerStep.SetGroupMetadata) {
|
||||
return composerState.hasError;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
export const isCreatingGroup = createSelector(
|
||||
getComposerState,
|
||||
(composerState): boolean =>
|
||||
composerState?.step === ComposerStep.SetGroupMetadata &&
|
||||
composerState.isCreating
|
||||
);
|
||||
|
||||
export const getMessages = createSelector(
|
||||
|
@ -273,6 +293,40 @@ export const getLeftPaneLists = createSelector(
|
|||
_getLeftPaneLists
|
||||
);
|
||||
|
||||
export const getMaximumGroupSizeModalState = createSelector(
|
||||
getComposerState,
|
||||
(composerState): OneTimeModalState => {
|
||||
switch (composerState?.step) {
|
||||
case ComposerStep.ChooseGroupMembers:
|
||||
case ComposerStep.SetGroupMetadata:
|
||||
return composerState.maximumGroupSizeModalState;
|
||||
default:
|
||||
assert(
|
||||
false,
|
||||
'Can\'t get the maximum group size modal state in this composer state; returning "never shown"'
|
||||
);
|
||||
return OneTimeModalState.NeverShown;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getRecommendedGroupSizeModalState = createSelector(
|
||||
getComposerState,
|
||||
(composerState): OneTimeModalState => {
|
||||
switch (composerState?.step) {
|
||||
case ComposerStep.ChooseGroupMembers:
|
||||
case ComposerStep.SetGroupMetadata:
|
||||
return composerState.recommendedGroupSizeModalState;
|
||||
default:
|
||||
assert(
|
||||
false,
|
||||
'Can\'t get the recommended group size modal state in this composer state; returning "never shown"'
|
||||
);
|
||||
return OneTimeModalState.NeverShown;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getMe = createSelector(
|
||||
[getConversationLookup, getUserConversationId],
|
||||
(
|
||||
|
@ -290,6 +344,13 @@ export const getComposerContactSearchTerm = createSelector(
|
|||
assert(false, 'getComposerContactSearchTerm: composer is not open');
|
||||
return '';
|
||||
}
|
||||
if (composer.step === ComposerStep.SetGroupMetadata) {
|
||||
assert(
|
||||
false,
|
||||
'getComposerContactSearchTerm: composer does not have a search term'
|
||||
);
|
||||
return '';
|
||||
}
|
||||
return composer.contactSearchTerm;
|
||||
}
|
||||
);
|
||||
|
@ -363,6 +424,102 @@ export const getComposeContacts = createSelector(
|
|||
}
|
||||
);
|
||||
|
||||
/*
|
||||
* This returns contacts for the composer when you're picking new group members. It casts
|
||||
* a wider net than `getContacts`.
|
||||
*/
|
||||
const getGroupContacts = createSelector(
|
||||
getConversationLookup,
|
||||
(conversationLookup): Array<ConversationType> =>
|
||||
Object.values(conversationLookup).filter(
|
||||
contact =>
|
||||
contact.type === 'direct' &&
|
||||
!contact.isMe &&
|
||||
!contact.isBlocked &&
|
||||
!isConversationUnregistered(contact)
|
||||
)
|
||||
);
|
||||
|
||||
export const getCandidateGroupContacts = createSelector(
|
||||
getNormalizedComposerContactSearchTerm,
|
||||
getGroupContacts,
|
||||
(searchTerm, contacts): Array<ConversationType> => {
|
||||
if (searchTerm.length) {
|
||||
return new Fuse<ConversationType>(
|
||||
contacts,
|
||||
COMPOSE_CONTACTS_FUSE_OPTIONS
|
||||
).search(searchTerm);
|
||||
}
|
||||
return contacts.concat().sort((a, b) => collator.compare(a.title, b.title));
|
||||
}
|
||||
);
|
||||
|
||||
export const getCantAddContactForModal = createSelector(
|
||||
getConversationLookup,
|
||||
getComposerState,
|
||||
(conversationLookup, composerState): undefined | ConversationType => {
|
||||
if (composerState?.step !== ComposerStep.ChooseGroupMembers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const conversationId = composerState.cantAddContactIdForModal;
|
||||
if (!conversationId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = getOwn(conversationLookup, conversationId);
|
||||
assert(
|
||||
result,
|
||||
'getCantAddContactForModal: failed to look up conversation by ID; returning undefined'
|
||||
);
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
const getGroupCreationComposerState = createSelector(
|
||||
getComposerState,
|
||||
(
|
||||
composerState
|
||||
): {
|
||||
groupName: string;
|
||||
groupAvatar: undefined | ArrayBuffer;
|
||||
selectedConversationIds: Array<string>;
|
||||
} => {
|
||||
switch (composerState?.step) {
|
||||
case ComposerStep.ChooseGroupMembers:
|
||||
case ComposerStep.SetGroupMetadata:
|
||||
return composerState;
|
||||
default:
|
||||
assert(
|
||||
false,
|
||||
'getSetGroupMetadataComposerState: expected step to be SetGroupMetadata'
|
||||
);
|
||||
return {
|
||||
groupName: '',
|
||||
groupAvatar: undefined,
|
||||
selectedConversationIds: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getComposeGroupAvatar = createSelector(
|
||||
getGroupCreationComposerState,
|
||||
(composerState): undefined | ArrayBuffer => composerState.groupAvatar
|
||||
);
|
||||
|
||||
export const getComposeGroupName = createSelector(
|
||||
getGroupCreationComposerState,
|
||||
(composerState): string => composerState.groupName
|
||||
);
|
||||
|
||||
export const getComposeSelectedContacts = createSelector(
|
||||
getConversationLookup,
|
||||
getGroupCreationComposerState,
|
||||
(conversationLookup, composerState): Array<ConversationType> =>
|
||||
deconstructLookup(conversationLookup, composerState.selectedConversationIds)
|
||||
);
|
||||
|
||||
// This is where we will put Conversation selector logic, replicating what
|
||||
// is currently in models/conversation.getProps()
|
||||
// What needs to happen to pull that selector logic here?
|
||||
|
@ -666,3 +823,16 @@ export const getConversationMessagesSelector = createSelector(
|
|||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const getInvitedContactsForNewlyCreatedGroup = createSelector(
|
||||
getConversationLookup,
|
||||
getConversations,
|
||||
(
|
||||
conversationLookup,
|
||||
{ invitedConversationIdsForNewlyCreatedGroup = [] }
|
||||
): Array<ConversationType> =>
|
||||
deconstructLookup(
|
||||
conversationLookup,
|
||||
invitedConversationIdsForNewlyCreatedGroup
|
||||
)
|
||||
);
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
12
ts/storage/isFeatureEnabled.ts
Normal file
12
ts/storage/isFeatureEnabled.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { isEnabled } from '../RemoteConfig';
|
||||
|
||||
function isStorageFeatureEnabled(): boolean {
|
||||
return isEnabled('desktop.storage');
|
||||
}
|
||||
|
||||
export function isStorageWriteFeatureEnabled(): boolean {
|
||||
return isStorageFeatureEnabled() && isEnabled('desktop.storageWrite2');
|
||||
}
|
67
ts/test-both/groups/limits_test.ts
Normal file
67
ts/test-both/groups/limits_test.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import * as remoteConfig from '../../RemoteConfig';
|
||||
|
||||
import {
|
||||
getGroupSizeRecommendedLimit,
|
||||
getGroupSizeHardLimit,
|
||||
} from '../../groups/limits';
|
||||
|
||||
describe('group limit utilities', () => {
|
||||
let sinonSandbox: sinon.SinonSandbox;
|
||||
let getRecommendedLimitStub: sinon.SinonStub;
|
||||
let getHardLimitStub: sinon.SinonStub;
|
||||
|
||||
beforeEach(() => {
|
||||
sinonSandbox = sinon.createSandbox();
|
||||
|
||||
const getValueStub = sinonSandbox.stub(remoteConfig, 'getValue');
|
||||
getRecommendedLimitStub = getValueStub.withArgs(
|
||||
'global.groupsv2.maxGroupSize'
|
||||
);
|
||||
getHardLimitStub = getValueStub.withArgs(
|
||||
'global.groupsv2.groupSizeHardLimit'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sinonSandbox.restore();
|
||||
});
|
||||
|
||||
describe('getGroupSizeRecommendedLimit', () => {
|
||||
it('throws if the value in remote config is not defined', () => {
|
||||
getRecommendedLimitStub.returns(undefined);
|
||||
assert.throws(getGroupSizeRecommendedLimit);
|
||||
});
|
||||
|
||||
it('throws if the value in remote config is not a parseable integer', () => {
|
||||
getRecommendedLimitStub.returns('uh oh');
|
||||
assert.throws(getGroupSizeRecommendedLimit);
|
||||
});
|
||||
|
||||
it('returns the value in remote config, parsed as an integer', () => {
|
||||
getRecommendedLimitStub.returns('123');
|
||||
assert.strictEqual(getGroupSizeRecommendedLimit(), 123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupSizeHardLimit', () => {
|
||||
it('throws if the value in remote config is not defined', () => {
|
||||
getHardLimitStub.returns(undefined);
|
||||
assert.throws(getGroupSizeHardLimit);
|
||||
});
|
||||
|
||||
it('throws if the value in remote config is not a parseable integer', () => {
|
||||
getHardLimitStub.returns('uh oh');
|
||||
assert.throws(getGroupSizeHardLimit);
|
||||
});
|
||||
|
||||
it('returns the value in remote config, parsed as an integer', () => {
|
||||
getHardLimitStub.returns('123');
|
||||
assert.strictEqual(getGroupSizeHardLimit(), 123);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 = {
|
||||
|
|
71
ts/test-both/util/parseIntOrThrow_test.ts
Normal file
71
ts/test-both/util/parseIntOrThrow_test.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { parseIntOrThrow } from '../../util/parseIntOrThrow';
|
||||
|
||||
describe('parseIntOrThrow', () => {
|
||||
describe('when passed a number argument', () => {
|
||||
it('returns the number when passed an integer', () => {
|
||||
assert.strictEqual(parseIntOrThrow(0, "shouldn't happen"), 0);
|
||||
assert.strictEqual(parseIntOrThrow(123, "shouldn't happen"), 123);
|
||||
assert.strictEqual(parseIntOrThrow(-123, "shouldn't happen"), -123);
|
||||
});
|
||||
|
||||
it('throws when passed a decimal value', () => {
|
||||
assert.throws(() => parseIntOrThrow(0.2, 'uh oh'), 'uh oh');
|
||||
assert.throws(() => parseIntOrThrow(1.23, 'uh oh'), 'uh oh');
|
||||
});
|
||||
|
||||
it('throws when passed NaN', () => {
|
||||
assert.throws(() => parseIntOrThrow(NaN, 'uh oh'), 'uh oh');
|
||||
});
|
||||
|
||||
it('throws when passed ∞', () => {
|
||||
assert.throws(() => parseIntOrThrow(Infinity, 'uh oh'), 'uh oh');
|
||||
assert.throws(() => parseIntOrThrow(-Infinity, 'uh oh'), 'uh oh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passed a string argument', () => {
|
||||
it('returns the number when passed an integer', () => {
|
||||
assert.strictEqual(parseIntOrThrow('0', "shouldn't happen"), 0);
|
||||
assert.strictEqual(parseIntOrThrow('123', "shouldn't happen"), 123);
|
||||
assert.strictEqual(parseIntOrThrow('-123', "shouldn't happen"), -123);
|
||||
});
|
||||
|
||||
it('parses decimal values like parseInt', () => {
|
||||
assert.strictEqual(parseIntOrThrow('0.2', "shouldn't happen"), 0);
|
||||
assert.strictEqual(parseIntOrThrow('12.34', "shouldn't happen"), 12);
|
||||
assert.strictEqual(parseIntOrThrow('-12.34', "shouldn't happen"), -12);
|
||||
});
|
||||
|
||||
it('parses values in base 10', () => {
|
||||
assert.strictEqual(parseIntOrThrow('0x12', "shouldn't happen"), 0);
|
||||
});
|
||||
|
||||
it('throws when passed non-parseable strings', () => {
|
||||
assert.throws(() => parseIntOrThrow('', 'uh oh'), 'uh oh');
|
||||
assert.throws(() => parseIntOrThrow('uh 123', 'uh oh'), 'uh oh');
|
||||
assert.throws(() => parseIntOrThrow('uh oh', 'uh oh'), 'uh oh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passed other arguments', () => {
|
||||
it("throws when passed arguments that aren't strings or numbers", () => {
|
||||
assert.throws(() => parseIntOrThrow(null, 'uh oh'), 'uh oh');
|
||||
assert.throws(() => parseIntOrThrow(undefined, 'uh oh'), 'uh oh');
|
||||
assert.throws(() => parseIntOrThrow(['123'], 'uh oh'), 'uh oh');
|
||||
});
|
||||
|
||||
it('throws when passed a stringifiable argument, unlike parseInt', () => {
|
||||
const obj = {
|
||||
toString() {
|
||||
return '123';
|
||||
},
|
||||
};
|
||||
assert.throws(() => parseIntOrThrow(obj, 'uh oh'), 'uh oh');
|
||||
});
|
||||
});
|
||||
});
|
71
ts/test-both/util/parseIntWithFallback_test.ts
Normal file
71
ts/test-both/util/parseIntWithFallback_test.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { parseIntWithFallback } from '../../util/parseIntWithFallback';
|
||||
|
||||
describe('parseIntWithFallback', () => {
|
||||
describe('when passed a number argument', () => {
|
||||
it('returns the number when passed an integer', () => {
|
||||
assert.strictEqual(parseIntWithFallback(0, -1), 0);
|
||||
assert.strictEqual(parseIntWithFallback(123, -1), 123);
|
||||
assert.strictEqual(parseIntWithFallback(-123, -1), -123);
|
||||
});
|
||||
|
||||
it('returns the fallback when passed a decimal value', () => {
|
||||
assert.strictEqual(parseIntWithFallback(0.2, -1), -1);
|
||||
assert.strictEqual(parseIntWithFallback(1.23, -1), -1);
|
||||
});
|
||||
|
||||
it('returns the fallback when passed NaN', () => {
|
||||
assert.strictEqual(parseIntWithFallback(NaN, -1), -1);
|
||||
});
|
||||
|
||||
it('returns the fallback when passed ∞', () => {
|
||||
assert.strictEqual(parseIntWithFallback(Infinity, -1), -1);
|
||||
assert.strictEqual(parseIntWithFallback(-Infinity, -1), -1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passed a string argument', () => {
|
||||
it('returns the number when passed an integer', () => {
|
||||
assert.strictEqual(parseIntWithFallback('0', -1), 0);
|
||||
assert.strictEqual(parseIntWithFallback('123', -1), 123);
|
||||
assert.strictEqual(parseIntWithFallback('-123', -1), -123);
|
||||
});
|
||||
|
||||
it('parses decimal values like parseInt', () => {
|
||||
assert.strictEqual(parseIntWithFallback('0.2', -1), 0);
|
||||
assert.strictEqual(parseIntWithFallback('12.34', -1), 12);
|
||||
assert.strictEqual(parseIntWithFallback('-12.34', -1), -12);
|
||||
});
|
||||
|
||||
it('parses values in base 10', () => {
|
||||
assert.strictEqual(parseIntWithFallback('0x12', -1), 0);
|
||||
});
|
||||
|
||||
it('returns the fallback when passed non-parseable strings', () => {
|
||||
assert.strictEqual(parseIntWithFallback('', -1), -1);
|
||||
assert.strictEqual(parseIntWithFallback('uh 123', -1), -1);
|
||||
assert.strictEqual(parseIntWithFallback('uh oh', -1), -1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passed other arguments', () => {
|
||||
it("returns the fallback when passed arguments that aren't strings or numbers", () => {
|
||||
assert.strictEqual(parseIntWithFallback(null, -1), -1);
|
||||
assert.strictEqual(parseIntWithFallback(undefined, -1), -1);
|
||||
assert.strictEqual(parseIntWithFallback(['123'], -1), -1);
|
||||
});
|
||||
|
||||
it('returns the fallback when passed a stringifiable argument, unlike parseInt', () => {
|
||||
const obj = {
|
||||
toString() {
|
||||
return '123';
|
||||
},
|
||||
};
|
||||
assert.strictEqual(parseIntWithFallback(obj, -1), -1);
|
||||
});
|
||||
});
|
||||
});
|
File diff suppressed because it is too large
Load diff
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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"
|
||||
|
|
24
ts/util/parseIntOrThrow.ts
Normal file
24
ts/util/parseIntOrThrow.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export function parseIntOrThrow(value: unknown, message: string): number {
|
||||
let result: number;
|
||||
|
||||
switch (typeof value) {
|
||||
case 'number':
|
||||
result = value;
|
||||
break;
|
||||
case 'string':
|
||||
result = parseInt(value, 10);
|
||||
break;
|
||||
default:
|
||||
result = NaN;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(result)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
12
ts/util/parseIntWithFallback.ts
Normal file
12
ts/util/parseIntWithFallback.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { parseIntOrThrow } from './parseIntOrThrow';
|
||||
|
||||
export function parseIntWithFallback(value: unknown, fallback: number): number {
|
||||
try {
|
||||
return parseIntOrThrow(value, 'Failed to parse');
|
||||
} catch (err) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue