Support for announcement-only groups
This commit is contained in:
parent
863ae9ed83
commit
56d5d283bd
43 changed files with 1057 additions and 455 deletions
|
@ -3502,6 +3502,18 @@
|
|||
"description": "Shown if you click on a sgnl:// link not currently supported by Desktop"
|
||||
},
|
||||
|
||||
"GroupV2--cannot-send": {
|
||||
"message": "You cannot send messages to that group.",
|
||||
"description": "Shown in toast when you attempt to forward a message to an announcement only group"
|
||||
},
|
||||
"GroupV2--add--missing-capability": {
|
||||
"message": "These people cannot be added to the group until they upgrade Signal.",
|
||||
"description": "Shown in a confirmation dialog when members who cannot view announcement only group cannot be added"
|
||||
},
|
||||
"GroupV2--cannot-start-group-call": {
|
||||
"message": "Only admins of the group can start a call.",
|
||||
"description": "Shown in toast when a non-admin starts a group call in an announcements only group"
|
||||
},
|
||||
"GroupV2--join--invalid-link--title": {
|
||||
"message": "Invalid Link",
|
||||
"description": "Shown if we are unable to parse a group link"
|
||||
|
@ -4699,6 +4711,43 @@
|
|||
"description": "Shown in timeline or conversation preview when v2 group changes"
|
||||
},
|
||||
|
||||
"GroupV2--announcements--admin--you": {
|
||||
"message": "You changed the group settings to only allow admins to send messages.",
|
||||
"description": "Shown in timeline or conversation preview when v2 group changes"
|
||||
},
|
||||
"GroupV2--announcements--admin--other": {
|
||||
"message": "$memberName$ changed the group settings to only allow admins to send messages.",
|
||||
"description": "Shown in timeline or conversation preview when v2 group changes",
|
||||
"placeholders": {
|
||||
"adminName": {
|
||||
"content": "$1",
|
||||
"example": "Alice"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GroupV2--announcements--admin--unknown": {
|
||||
"message": "The group was changed to only allow admins to send messages.",
|
||||
"description": "Shown in timeline or conversation preview when v2 group changes"
|
||||
},
|
||||
"GroupV2--announcements--member--you": {
|
||||
"message": "You changed the group settings to allow all members to send messages.",
|
||||
"description": "Shown in timeline or conversation preview when v2 group changes"
|
||||
},
|
||||
"GroupV2--announcements--member--other": {
|
||||
"message": "$memberName$ changed the group settings to allow all members to send messages.",
|
||||
"description": "Shown in timeline or conversation preview when v2 group changes",
|
||||
"placeholders": {
|
||||
"adminName": {
|
||||
"content": "$1",
|
||||
"example": "Alice"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GroupV2--announcements--member--unknown": {
|
||||
"message": "The group was changed to allow all members to send messages.",
|
||||
"description": "Shown in timeline or conversation preview when v2 group changes"
|
||||
},
|
||||
|
||||
"GroupV1--Migration--disabled": {
|
||||
"message": "Upgrade this group to activate new features like @mentions and admins. Members who have not shared their name or photo in this group will be invited to join. $learnMore$",
|
||||
"description": "Shown instead of composition area when user is forced to migrate a legacy group (GV1).",
|
||||
|
@ -4921,6 +4970,14 @@
|
|||
"message": "Choose who can add members to this group.",
|
||||
"description": "This is the additional info for the 'who can add members' panel"
|
||||
},
|
||||
"ConversationDetails--announcement-label": {
|
||||
"message": "Who can send messages",
|
||||
"description": "This is the additional info for the 'who can send messages' panel"
|
||||
},
|
||||
"ConversationDetails--announcement-info": {
|
||||
"message": "Choose who can send messages to the group.",
|
||||
"description": "This is the additional info for the 'who can send mesages' panel"
|
||||
},
|
||||
"ConversationDetails--requests-and-invites": {
|
||||
"message": "Requests & Invites",
|
||||
"description": "This is a button to display which members have been invited but have not joined yet"
|
||||
|
@ -5740,5 +5797,23 @@
|
|||
"ProfileEditorModal--error": {
|
||||
"message": "Your profile could not be updated. Please try again.",
|
||||
"description": "Error message when something goes wrong updating your profile."
|
||||
},
|
||||
"AnnouncementsOnlyGroupBanner--modal": {
|
||||
"message": "Message an admin",
|
||||
"description": "Modal title for the list of admins in a group"
|
||||
},
|
||||
"AnnouncementsOnlyGroupBanner--announcements-only": {
|
||||
"message": "Only $admins$ can send messages",
|
||||
"description": "Displayed if sending of messages is disabled to non-admins",
|
||||
"placeholders": {
|
||||
"admins": {
|
||||
"content": "$1",
|
||||
"example": "admins"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AnnouncementsOnlyGroupBanner--admins": {
|
||||
"message": "admins",
|
||||
"description": "Clickable text describing administrators of a group, used in the message an admin label"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -113,7 +113,7 @@
|
|||
<div class='compose'>
|
||||
<form class='send clearfix file-input'>
|
||||
<input type="file" class="file-input" multiple="multiple">
|
||||
<div class='composition-area-placeholder'></div>
|
||||
<div class='CompositionArea__placeholder'></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Copyright 2014-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package signalservice;
|
||||
|
||||
message ProvisioningUuid {
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Copyright 2018-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package signalservice;
|
||||
|
||||
message DeviceName {
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
syntax = "proto3";
|
||||
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package signalservice;
|
||||
|
||||
option java_package = "org.whispersystems.signalservice.protos.groups";
|
||||
|
@ -68,6 +71,7 @@ message Group {
|
|||
repeated MemberPendingAdminApproval membersPendingAdminApproval = 9;
|
||||
bytes inviteLinkPassword = 10;
|
||||
bytes descriptionBytes = 11;
|
||||
bool announcementsOnly = 12;
|
||||
}
|
||||
|
||||
message GroupChange {
|
||||
|
@ -153,6 +157,10 @@ message GroupChange {
|
|||
bytes descriptionBytes = 1;
|
||||
}
|
||||
|
||||
message ModifyAnnouncementsOnlyAction {
|
||||
bool announcementsOnly = 1;
|
||||
}
|
||||
|
||||
|
||||
bytes sourceUuid = 1; // Who made the change
|
||||
uint32 version = 2; // The change version number
|
||||
|
@ -174,6 +182,7 @@ message GroupChange {
|
|||
repeated PromoteMemberPendingAdminApprovalAction promoteMemberPendingAdminApprovals = 18; // change epoch = 1
|
||||
ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1
|
||||
ModifyDescriptionAction modifyDescription = 20; // change epoch = 2
|
||||
ModifyAnnouncementsOnlyAction modifyAnnouncementsOnly = 21; // change epoch = 3
|
||||
}
|
||||
|
||||
bytes actions = 1; // The serialized actions
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Copyright 2014-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Source: https://github.com/signalapp/libsignal-service-java/blob/4684a49b2ed8f32be619e0d0eea423626b6cb2cb/protobuf/SignalService.proto
|
||||
package signalservice;
|
||||
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package signalservice;
|
||||
|
||||
option java_package = "org.whispersystems.signalservice.internal.storage";
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Copyright 2019-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package signalservice;
|
||||
|
||||
message StickerPack {
|
||||
|
|
|
@ -1,19 +1,6 @@
|
|||
/**
|
||||
* Copyright (C) 2014 Open WhisperSystems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// Copyright 2014-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package signalservice;
|
||||
|
||||
option java_package = "org.whispersystems.websocket.messages.protobuf";
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Copyright 2018-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package signalservice;
|
||||
|
||||
option java_package = "org.whispersystems.libsignal.protocol";
|
||||
|
|
|
@ -8618,191 +8618,6 @@ button.module-image__border-overlay:focus {
|
|||
}
|
||||
}
|
||||
|
||||
// Module: CompositionArea
|
||||
.module-composition-area {
|
||||
position: relative;
|
||||
min-height: 42px;
|
||||
padding-top: 6px;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
&--center {
|
||||
justify-content: center;
|
||||
}
|
||||
&--padded {
|
||||
padding: 0 12px;
|
||||
}
|
||||
&--control-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
&--column {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
&__button-cell {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
&--mic-active {
|
||||
width: 150px;
|
||||
}
|
||||
&--large-right {
|
||||
margin-left: auto;
|
||||
margin-right: 4px;
|
||||
}
|
||||
&--large-right-mic-active {
|
||||
margin-left: auto;
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
&__send-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
&::after {
|
||||
display: block;
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
@include color-svg('../images/icons/v2/send-24.svg', $color-ultramarine);
|
||||
}
|
||||
}
|
||||
&__input {
|
||||
flex-grow: 1;
|
||||
}
|
||||
$comp-area: &;
|
||||
&__toggle-large {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
position: absolute;
|
||||
left: calc(50% - 24px);
|
||||
top: -18px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
|
||||
#{$comp-area}:hover & {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
@include light-theme() {
|
||||
background-color: $color-white;
|
||||
}
|
||||
|
||||
@include dark-theme() {
|
||||
background-color: $color-gray-95;
|
||||
}
|
||||
|
||||
&__button {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
|
||||
@include light-theme() {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/expand-up-20.svg',
|
||||
$color-gray-45,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@include dark-theme() {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/expand-up-20.svg',
|
||||
$color-gray-45,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
&--large-active {
|
||||
@include light-theme() {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/collapse-down-20.svg',
|
||||
$color-gray-45,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@include dark-theme() {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/collapse-down-20.svg',
|
||||
$color-gray-45,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&__attachment-list {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.composition-area-placeholder {
|
||||
flex-grow: 1;
|
||||
margin: {
|
||||
bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.module-composition-area--sms-only {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
// Note the margine in .composition-area-placeholder above
|
||||
padding: 14px 16px 18px 16px;
|
||||
|
||||
&:not(.module-composition-area--pending) {
|
||||
@include light-theme {
|
||||
border-top: 1px solid $color-gray-05;
|
||||
}
|
||||
@include dark-theme {
|
||||
border-top: 1px solid $color-gray-75;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include font-body-2-bold;
|
||||
margin: 0 0 2px 0;
|
||||
|
||||
@include light-theme {
|
||||
color: $color-gray-60;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
@include font-body-2;
|
||||
text-align: center;
|
||||
|
||||
margin: 0;
|
||||
|
||||
@include light-theme {
|
||||
color: $color-gray-60;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Module: Last Seen Indicator
|
||||
|
||||
.module-last-seen-indicator {
|
||||
|
|
24
stylesheets/components/AnnouncementsOnlyGroupBanner.scss
Normal file
24
stylesheets/components/AnnouncementsOnlyGroupBanner.scss
Normal file
|
@ -0,0 +1,24 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.AnnouncementsOnlyGroupBanner {
|
||||
&__banner {
|
||||
@include font-subtitle;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
|
||||
@include light-theme {
|
||||
border-top: 1px solid $color-gray-05;
|
||||
color: $color-gray-60;
|
||||
}
|
||||
@include dark-theme {
|
||||
border-top: 1px solid $color-gray-05;
|
||||
color: $color-gray-05;
|
||||
}
|
||||
|
||||
&--admins {
|
||||
@include button-reset;
|
||||
color: $color-ultramarine;
|
||||
}
|
||||
}
|
||||
}
|
183
stylesheets/components/CompositionArea.scss
Normal file
183
stylesheets/components/CompositionArea.scss
Normal file
|
@ -0,0 +1,183 @@
|
|||
.CompositionArea {
|
||||
position: relative;
|
||||
min-height: 42px;
|
||||
padding-top: 6px;
|
||||
|
||||
&__placeholder {
|
||||
flex-grow: 1;
|
||||
margin: {
|
||||
bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
&--center {
|
||||
justify-content: center;
|
||||
}
|
||||
&--padded {
|
||||
padding: 0 12px;
|
||||
}
|
||||
&--control-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
&--column {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
&__button-cell {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
&--mic-active {
|
||||
width: 150px;
|
||||
}
|
||||
&--large-right {
|
||||
margin-left: auto;
|
||||
margin-right: 4px;
|
||||
}
|
||||
&--large-right-mic-active {
|
||||
margin-left: auto;
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
&__send-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
&::after {
|
||||
display: block;
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
@include color-svg('../images/icons/v2/send-24.svg', $color-ultramarine);
|
||||
}
|
||||
}
|
||||
&__input {
|
||||
flex-grow: 1;
|
||||
}
|
||||
$comp-area: &;
|
||||
&__toggle-large {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
position: absolute;
|
||||
left: calc(50% - 24px);
|
||||
top: -18px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
|
||||
#{$comp-area}:hover & {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
@include light-theme() {
|
||||
background-color: $color-white;
|
||||
}
|
||||
|
||||
@include dark-theme() {
|
||||
background-color: $color-gray-95;
|
||||
}
|
||||
|
||||
&__button {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
|
||||
@include light-theme() {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/expand-up-20.svg',
|
||||
$color-gray-45,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@include dark-theme() {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/expand-up-20.svg',
|
||||
$color-gray-45,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
&--large-active {
|
||||
@include light-theme() {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/collapse-down-20.svg',
|
||||
$color-gray-45,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@include dark-theme() {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/collapse-down-20.svg',
|
||||
$color-gray-45,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&__attachment-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--sms-only {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
// Note the margin in &__placeholder above
|
||||
padding: 14px 16px 18px 16px;
|
||||
|
||||
&:not(.module-composition-area--pending) {
|
||||
@include light-theme {
|
||||
border-top: 1px solid $color-gray-05;
|
||||
}
|
||||
@include dark-theme {
|
||||
border-top: 1px solid $color-gray-75;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include font-body-2-bold;
|
||||
margin: 0 0 2px 0;
|
||||
|
||||
@include light-theme {
|
||||
color: $color-gray-60;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
@include font-body-2;
|
||||
text-align: center;
|
||||
|
||||
margin: 0;
|
||||
|
||||
@include light-theme {
|
||||
color: $color-gray-60;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -195,6 +195,10 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
&--show-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@include light-theme {
|
||||
&:hover,
|
||||
&:focus {
|
||||
|
|
|
@ -29,12 +29,14 @@
|
|||
// New style: components
|
||||
@import './components/AddGroupMembersModal.scss';
|
||||
@import './components/App.scss';
|
||||
@import './components/AnnouncementsOnlyGroupBanner.scss';
|
||||
@import './components/Avatar.scss';
|
||||
@import './components/AvatarInput.scss';
|
||||
@import './components/Button.scss';
|
||||
@import './components/CallingScreenSharingController.scss';
|
||||
@import './components/CallingSelectPresentingSourcesModal.scss';
|
||||
@import './components/ChatColorPicker.scss';
|
||||
@import './components/CompositionArea.scss';
|
||||
@import './components/ContactName.scss';
|
||||
@import './components/ContactPill.scss';
|
||||
@import './components/ContactPills.scss';
|
||||
|
|
|
@ -95,7 +95,7 @@
|
|||
<div class='compose'>
|
||||
<form class='send clearfix file-input'>
|
||||
<input type="file" class="file-input" multiple="multiple">
|
||||
<div class='composition-area-placeholder'></div>
|
||||
<div class='CompositionArea__placeholder'></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1198,7 +1198,7 @@ export async function startApp(): Promise<void> {
|
|||
'.module-conversation-list__item--contact-or-conversation'
|
||||
),
|
||||
document.querySelector('.module-search-results'),
|
||||
document.querySelector('.module-composition-area .ql-editor'),
|
||||
document.querySelector('.CompositionArea .ql-editor'),
|
||||
];
|
||||
const focusedIndex = targets.findIndex(target => {
|
||||
if (!target || !focusedElement) {
|
||||
|
@ -2318,6 +2318,7 @@ export async function startApp(): Promise<void> {
|
|||
// Note: we always have to register our capabilities all at once, so we do this
|
||||
// after connect on every startup
|
||||
await server.registerCapabilities({
|
||||
announcementGroup: true,
|
||||
'gv2-3': true,
|
||||
'gv1-migration': true,
|
||||
senderKey: window.Signal.RemoteConfig.isEnabled(
|
||||
|
|
67
ts/components/AnnouncementsOnlyGroupBanner.tsx
Normal file
67
ts/components/AnnouncementsOnlyGroupBanner.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { Intl } from './Intl';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { Modal } from './Modal';
|
||||
import { ConversationListItem } from './conversationList/ConversationListItem';
|
||||
|
||||
type PropsType = {
|
||||
groupAdmins: Array<ConversationType>;
|
||||
i18n: LocalizerType;
|
||||
openConversation: (conversationId: string) => unknown;
|
||||
};
|
||||
|
||||
export const AnnouncementsOnlyGroupBanner = ({
|
||||
groupAdmins,
|
||||
i18n,
|
||||
openConversation,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [isShowingAdmins, setIsShowingAdmins] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isShowingAdmins && (
|
||||
<Modal
|
||||
i18n={i18n}
|
||||
onClose={() => setIsShowingAdmins(false)}
|
||||
title={i18n('AnnouncementsOnlyGroupBanner--modal')}
|
||||
>
|
||||
{groupAdmins.map(admin => (
|
||||
<ConversationListItem
|
||||
{...admin}
|
||||
i18n={i18n}
|
||||
onClick={() => {
|
||||
openConversation(admin.id);
|
||||
}}
|
||||
// Required by the component but unecessary for us
|
||||
style={{}}
|
||||
// We don't want these values to show
|
||||
draftPreview=""
|
||||
lastMessage={undefined}
|
||||
lastUpdated={undefined}
|
||||
typingContact={undefined}
|
||||
/>
|
||||
))}
|
||||
</Modal>
|
||||
)}
|
||||
<div className="AnnouncementsOnlyGroupBanner__banner">
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="AnnouncementsOnlyGroupBanner--announcements-only"
|
||||
components={[
|
||||
<button
|
||||
className="AnnouncementsOnlyGroupBanner__banner--admins"
|
||||
type="button"
|
||||
onClick={() => setIsShowingAdmins(true)}
|
||||
>
|
||||
{i18n('AnnouncementsOnlyGroupBanner--admins')}
|
||||
</button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -91,7 +91,14 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
title: '',
|
||||
// GroupV1 Disabled Actions
|
||||
onStartGroupMigration: action('onStartGroupMigration'),
|
||||
// GroupV2 Pending Approval Actions
|
||||
// GroupV2
|
||||
announcementsOnly: boolean(
|
||||
'announcementsOnly',
|
||||
Boolean(overrideProps.announcementsOnly)
|
||||
),
|
||||
areWeAdmin: boolean('areWeAdmin', Boolean(overrideProps.areWeAdmin)),
|
||||
groupAdmins: [],
|
||||
openConversation: action('openConversation'),
|
||||
onCancelJoinRequest: action('onCancelJoinRequest'),
|
||||
// SMS-only
|
||||
isSMSOnly: overrideProps.isSMSOnly || false,
|
||||
|
@ -157,3 +164,12 @@ story.add('Attachments', () => {
|
|||
|
||||
return <CompositionArea {...props} />;
|
||||
});
|
||||
|
||||
story.add('Announcements Only group', () => (
|
||||
<CompositionArea
|
||||
{...createProps({
|
||||
announcementsOnly: true,
|
||||
areWeAdmin: false,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -37,11 +37,16 @@ import { MediaQualitySelector } from './MediaQualitySelector';
|
|||
import { Quote, Props as QuoteProps } from './conversation/Quote';
|
||||
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
||||
import { LinkPreviewWithDomain } from '../types/LinkPreview';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { AnnouncementsOnlyGroupBanner } from './AnnouncementsOnlyGroupBanner';
|
||||
|
||||
export type OwnProps = {
|
||||
readonly i18n: LocalizerType;
|
||||
readonly areWePending?: boolean;
|
||||
readonly areWePendingApproval?: boolean;
|
||||
readonly announcementsOnly?: boolean;
|
||||
readonly areWeAdmin?: boolean;
|
||||
readonly groupAdmins: Array<ConversationType>;
|
||||
readonly groupVersion?: 1 | 2;
|
||||
readonly isGroupV1AndDisabled?: boolean;
|
||||
readonly isMissingMandatoryProfileSharing?: boolean;
|
||||
|
@ -74,6 +79,7 @@ export type OwnProps = {
|
|||
linkPreviewLoading: boolean;
|
||||
linkPreviewResult?: LinkPreviewWithDomain;
|
||||
onCloseLinkPreview(): unknown;
|
||||
openConversation(conversationId: string): unknown;
|
||||
};
|
||||
|
||||
export type Props = Pick<
|
||||
|
@ -188,8 +194,12 @@ export const CompositionArea = ({
|
|||
// GroupV1 Disabled Actions
|
||||
isGroupV1AndDisabled,
|
||||
onStartGroupMigration,
|
||||
// GroupV2 Pending Approval Actions
|
||||
// GroupV2
|
||||
announcementsOnly,
|
||||
areWeAdmin,
|
||||
groupAdmins,
|
||||
onCancelJoinRequest,
|
||||
openConversation,
|
||||
// SMS-only contacts
|
||||
isSMSOnly,
|
||||
isFetchingUUID,
|
||||
|
@ -283,7 +293,7 @@ export const CompositionArea = ({
|
|||
|
||||
const leftHandSideButtonsFragment = (
|
||||
<>
|
||||
<div className="module-composition-area__button-cell">
|
||||
<div className="CompositionArea__button-cell">
|
||||
<EmojiButton
|
||||
i18n={i18n}
|
||||
doSend={handleForceSend}
|
||||
|
@ -295,7 +305,7 @@ export const CompositionArea = ({
|
|||
/>
|
||||
</div>
|
||||
{showMediaQualitySelector ? (
|
||||
<div className="module-composition-area__button-cell">
|
||||
<div className="CompositionArea__button-cell">
|
||||
<MediaQualitySelector
|
||||
i18n={i18n}
|
||||
isHighQuality={shouldSendHighQualityAttachments}
|
||||
|
@ -309,11 +319,11 @@ export const CompositionArea = ({
|
|||
const micButtonFragment = showMic ? (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-area__button-cell',
|
||||
micActive ? 'module-composition-area__button-cell--mic-active' : null,
|
||||
large ? 'module-composition-area__button-cell--large-right' : null,
|
||||
'CompositionArea__button-cell',
|
||||
micActive ? 'CompositionArea__button-cell--mic-active' : null,
|
||||
large ? 'CompositionArea__button-cell--large-right' : null,
|
||||
micActive && large
|
||||
? 'module-composition-area__button-cell--large-right-mic-active'
|
||||
? 'CompositionArea__button-cell--large-right-mic-active'
|
||||
: null
|
||||
)}
|
||||
ref={micCellRef}
|
||||
|
@ -321,7 +331,7 @@ export const CompositionArea = ({
|
|||
) : null;
|
||||
|
||||
const attButton = (
|
||||
<div className="module-composition-area__button-cell">
|
||||
<div className="CompositionArea__button-cell">
|
||||
<div className="choose-file">
|
||||
<button
|
||||
type="button"
|
||||
|
@ -336,13 +346,13 @@ export const CompositionArea = ({
|
|||
const sendButtonFragment = (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-area__button-cell',
|
||||
large ? 'module-composition-area__button-cell--large-right' : null
|
||||
'CompositionArea__button-cell',
|
||||
large ? 'CompositionArea__button-cell--large-right' : null
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="module-composition-area__send-button"
|
||||
className="CompositionArea__send-button"
|
||||
onClick={handleForceSend}
|
||||
aria-label={i18n('sendMessageToContact')}
|
||||
/>
|
||||
|
@ -351,7 +361,7 @@ export const CompositionArea = ({
|
|||
|
||||
const stickerButtonPlacement = large ? 'top-start' : 'top-end';
|
||||
const stickerButtonFragment = withStickers ? (
|
||||
<div className="module-composition-area__button-cell">
|
||||
<div className="CompositionArea__button-cell">
|
||||
<StickerButton
|
||||
i18n={i18n}
|
||||
knownPacks={knownPacks}
|
||||
|
@ -422,9 +432,9 @@ export const CompositionArea = ({
|
|||
return (
|
||||
<div
|
||||
className={classNames([
|
||||
'module-composition-area',
|
||||
'module-composition-area--sms-only',
|
||||
isFetchingUUID ? 'module-composition-area--pending' : null,
|
||||
'CompositionArea',
|
||||
'CompositionArea--sms-only',
|
||||
isFetchingUUID ? 'CompositionArea--pending' : null,
|
||||
])}
|
||||
>
|
||||
{isFetchingUUID ? (
|
||||
|
@ -436,10 +446,10 @@ export const CompositionArea = ({
|
|||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="module-composition-area--sms-only__title">
|
||||
<h2 className="CompositionArea--sms-only__title">
|
||||
{i18n('CompositionArea--sms-only__title')}
|
||||
</h2>
|
||||
<p className="module-composition-area--sms-only__body">
|
||||
<p className="CompositionArea--sms-only__body">
|
||||
{i18n('CompositionArea--sms-only__body')}
|
||||
</p>
|
||||
</>
|
||||
|
@ -490,16 +500,24 @@ export const CompositionArea = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (announcementsOnly && !areWeAdmin) {
|
||||
return (
|
||||
<AnnouncementsOnlyGroupBanner
|
||||
groupAdmins={groupAdmins}
|
||||
i18n={i18n}
|
||||
openConversation={openConversation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-composition-area">
|
||||
<div className="module-composition-area__toggle-large">
|
||||
<div className="CompositionArea">
|
||||
<div className="CompositionArea__toggle-large">
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
'module-composition-area__toggle-large__button',
|
||||
large
|
||||
? 'module-composition-area__toggle-large__button--large-active'
|
||||
: null
|
||||
'CompositionArea__toggle-large__button',
|
||||
large ? 'CompositionArea__toggle-large__button--large-active' : null
|
||||
)}
|
||||
// This prevents the user from tabbing here
|
||||
tabIndex={-1}
|
||||
|
@ -509,8 +527,8 @@ export const CompositionArea = ({
|
|||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-area__row',
|
||||
'module-composition-area__row--column'
|
||||
'CompositionArea__row',
|
||||
'CompositionArea__row--column'
|
||||
)}
|
||||
>
|
||||
{quotedMessageProps && (
|
||||
|
@ -539,7 +557,7 @@ export const CompositionArea = ({
|
|||
</div>
|
||||
)}
|
||||
{draftAttachments.length ? (
|
||||
<div className="module-composition-area__attachment-list">
|
||||
<div className="CompositionArea__attachment-list">
|
||||
<AttachmentList
|
||||
attachments={draftAttachments}
|
||||
i18n={i18n}
|
||||
|
@ -553,12 +571,12 @@ export const CompositionArea = ({
|
|||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-area__row',
|
||||
large ? 'module-composition-area__row--padded' : null
|
||||
'CompositionArea__row',
|
||||
large ? 'CompositionArea__row--padded' : null
|
||||
)}
|
||||
>
|
||||
{!large ? leftHandSideButtonsFragment : null}
|
||||
<div className="module-composition-area__input">
|
||||
<div className="CompositionArea__input">
|
||||
<CompositionInput
|
||||
i18n={i18n}
|
||||
disabled={disabled}
|
||||
|
@ -588,8 +606,8 @@ export const CompositionArea = ({
|
|||
{large ? (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-area__row',
|
||||
'module-composition-area__row--control-row'
|
||||
'CompositionArea__row',
|
||||
'CompositionArea__row--control-row'
|
||||
)}
|
||||
>
|
||||
{leftHandSideButtonsFragment}
|
||||
|
|
|
@ -118,3 +118,15 @@ story.add('media attachments', () => {
|
|||
/>
|
||||
);
|
||||
});
|
||||
|
||||
story.add('announcement only groups non-admin', () => (
|
||||
<ForwardMessageModal
|
||||
{...createProps()}
|
||||
candidateConversations={[
|
||||
getDefaultConversation({
|
||||
announcementsOnly: true,
|
||||
areWeAdmin: false,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -17,6 +17,7 @@ import { AttachmentList } from './conversation/AttachmentList';
|
|||
import { AttachmentType } from '../types/Attachment';
|
||||
import { Button } from './Button';
|
||||
import { CompositionInput, InputApi } from './CompositionInput';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
||||
import { ConversationList, Row, RowType } from './ConversationList';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
|
@ -92,6 +93,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
|||
const [attachmentsToForward, setAttachmentsToForward] = useState(attachments);
|
||||
const [isEditingMessage, setIsEditingMessage] = useState(false);
|
||||
const [messageBodyText, setMessageBodyText] = useState(messageBody || '');
|
||||
const [cannotMessage, setCannotMessage] = useState(false);
|
||||
|
||||
const isMessageEditable = !isSticker;
|
||||
|
||||
|
@ -186,7 +188,11 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
|||
}
|
||||
const selectedContact = contactLookup.get(conversationId);
|
||||
if (selectedContact) {
|
||||
setSelectedContacts([...nextSelectedContacts, selectedContact]);
|
||||
if (selectedContact.announcementsOnly && !selectedContact.areWeAdmin) {
|
||||
setCannotMessage(true);
|
||||
} else {
|
||||
setSelectedContacts([...nextSelectedContacts, selectedContact]);
|
||||
}
|
||||
}
|
||||
},
|
||||
[contactLookup, selectedContacts, setSelectedContacts]
|
||||
|
@ -233,183 +239,194 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<ModalHost onEscape={handleBackOrClose} onClose={onClose}>
|
||||
<div className="module-ForwardMessageModal">
|
||||
<div
|
||||
className={classNames('module-ForwardMessageModal__header', {
|
||||
'module-ForwardMessageModal__header--edit': isEditingMessage,
|
||||
})}
|
||||
<>
|
||||
{cannotMessage && (
|
||||
<ConfirmationDialog
|
||||
cancelText={i18n('Confirmation--confirm')}
|
||||
i18n={i18n}
|
||||
onClose={() => setCannotMessage(false)}
|
||||
>
|
||||
{i18n('GroupV2--cannot-send')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
<ModalHost onEscape={handleBackOrClose} onClose={onClose}>
|
||||
<div className="module-ForwardMessageModal">
|
||||
<div
|
||||
className={classNames('module-ForwardMessageModal__header', {
|
||||
'module-ForwardMessageModal__header--edit': isEditingMessage,
|
||||
})}
|
||||
>
|
||||
{isEditingMessage ? (
|
||||
<button
|
||||
aria-label={i18n('back')}
|
||||
className="module-ForwardMessageModal__header--back"
|
||||
onClick={() => setIsEditingMessage(false)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-label={i18n('close')}
|
||||
className="module-ForwardMessageModal__header--close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
<h1>{i18n('forwardMessage')}</h1>
|
||||
</div>
|
||||
{isEditingMessage ? (
|
||||
<button
|
||||
aria-label={i18n('back')}
|
||||
className="module-ForwardMessageModal__header--back"
|
||||
onClick={() => setIsEditingMessage(false)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-label={i18n('close')}
|
||||
className="module-ForwardMessageModal__header--close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
<h1>{i18n('forwardMessage')}</h1>
|
||||
</div>
|
||||
{isEditingMessage ? (
|
||||
<div className="module-ForwardMessageModal__main-body">
|
||||
{linkPreview ? (
|
||||
<div className="module-ForwardMessageModal--link-preview">
|
||||
<StagedLinkPreview
|
||||
date={linkPreview.date || null}
|
||||
description={linkPreview.description || ''}
|
||||
domain={linkPreview.url}
|
||||
<div className="module-ForwardMessageModal__main-body">
|
||||
{linkPreview ? (
|
||||
<div className="module-ForwardMessageModal--link-preview">
|
||||
<StagedLinkPreview
|
||||
date={linkPreview.date || null}
|
||||
description={linkPreview.description || ''}
|
||||
domain={linkPreview.url}
|
||||
i18n={i18n}
|
||||
image={linkPreview.image}
|
||||
onClose={() => removeLinkPreview()}
|
||||
title={linkPreview.title}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{attachmentsToForward && attachmentsToForward.length ? (
|
||||
<AttachmentList
|
||||
attachments={attachmentsToForward}
|
||||
i18n={i18n}
|
||||
image={linkPreview.image}
|
||||
onClose={() => removeLinkPreview()}
|
||||
title={linkPreview.title}
|
||||
onCloseAttachment={(attachment: AttachmentType) => {
|
||||
const newAttachments = attachmentsToForward.filter(
|
||||
currentAttachment => currentAttachment !== attachment
|
||||
);
|
||||
setAttachmentsToForward(newAttachments);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{attachmentsToForward && attachmentsToForward.length ? (
|
||||
<AttachmentList
|
||||
attachments={attachmentsToForward}
|
||||
i18n={i18n}
|
||||
onCloseAttachment={(attachment: AttachmentType) => {
|
||||
const newAttachments = attachmentsToForward.filter(
|
||||
currentAttachment => currentAttachment !== attachment
|
||||
);
|
||||
setAttachmentsToForward(newAttachments);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className="module-ForwardMessageModal__text-edit-area">
|
||||
<CompositionInput
|
||||
clearQuotedMessage={shouldNeverBeCalled}
|
||||
draftText={messageBodyText}
|
||||
getQuotedMessage={noop}
|
||||
i18n={i18n}
|
||||
inputApi={inputApiRef}
|
||||
large
|
||||
moduleClassName="module-ForwardMessageModal__input"
|
||||
onEditorStateChange={(
|
||||
messageText,
|
||||
bodyRanges,
|
||||
caretLocation
|
||||
) => {
|
||||
setMessageBodyText(messageText);
|
||||
onEditorStateChange(messageText, bodyRanges, caretLocation);
|
||||
}}
|
||||
onPickEmoji={onPickEmoji}
|
||||
onSubmit={forwardMessage}
|
||||
onTextTooLong={onTextTooLong}
|
||||
/>
|
||||
<div className="module-ForwardMessageModal__emoji">
|
||||
<EmojiButton
|
||||
) : null}
|
||||
<div className="module-ForwardMessageModal__text-edit-area">
|
||||
<CompositionInput
|
||||
clearQuotedMessage={shouldNeverBeCalled}
|
||||
draftText={messageBodyText}
|
||||
getQuotedMessage={noop}
|
||||
i18n={i18n}
|
||||
onClose={focusTextEditInput}
|
||||
onPickEmoji={insertEmoji}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
recentEmojis={recentEmojis}
|
||||
skinTone={skinTone}
|
||||
inputApi={inputApiRef}
|
||||
large
|
||||
moduleClassName="module-ForwardMessageModal__input"
|
||||
onEditorStateChange={(
|
||||
messageText,
|
||||
bodyRanges,
|
||||
caretLocation
|
||||
) => {
|
||||
setMessageBodyText(messageText);
|
||||
onEditorStateChange(messageText, bodyRanges, caretLocation);
|
||||
}}
|
||||
onPickEmoji={onPickEmoji}
|
||||
onSubmit={forwardMessage}
|
||||
onTextTooLong={onTextTooLong}
|
||||
/>
|
||||
<div className="module-ForwardMessageModal__emoji">
|
||||
<EmojiButton
|
||||
i18n={i18n}
|
||||
onClose={focusTextEditInput}
|
||||
onPickEmoji={insertEmoji}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
recentEmojis={recentEmojis}
|
||||
skinTone={skinTone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="module-ForwardMessageModal__main-body">
|
||||
<SearchInput
|
||||
disabled={candidateConversations.length === 0}
|
||||
placeholder={i18n('contactSearchPlaceholder')}
|
||||
onChange={event => {
|
||||
setSearchTerm(event.target.value);
|
||||
}}
|
||||
ref={inputRef}
|
||||
value={searchTerm}
|
||||
/>
|
||||
{candidateConversations.length ? (
|
||||
<Measure bounds>
|
||||
{({ contentRect, measureRef }: MeasuredComponentProps) => {
|
||||
// We disable this ESLint rule because we're capturing a bubbled keydown
|
||||
// event. See [this note in the jsx-a11y docs][0].
|
||||
//
|
||||
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
return (
|
||||
<div
|
||||
className="module-ForwardMessageModal__list-wrapper"
|
||||
ref={measureRef}
|
||||
>
|
||||
<ConversationList
|
||||
dimensions={contentRect.bounds}
|
||||
getRow={getRow}
|
||||
i18n={i18n}
|
||||
onClickArchiveButton={shouldNeverBeCalled}
|
||||
onClickContactCheckbox={(
|
||||
conversationId: string,
|
||||
disabledReason:
|
||||
| undefined
|
||||
| ContactCheckboxDisabledReason
|
||||
) => {
|
||||
if (
|
||||
disabledReason !==
|
||||
ContactCheckboxDisabledReason.MaximumContactsSelected
|
||||
) {
|
||||
toggleSelectedConversation(conversationId);
|
||||
}
|
||||
}}
|
||||
onSelectConversation={shouldNeverBeCalled}
|
||||
renderMessageSearchResult={() => {
|
||||
shouldNeverBeCalled();
|
||||
return <div />;
|
||||
}}
|
||||
rowCount={rowCount}
|
||||
shouldRecomputeRowHeights={false}
|
||||
showChooseGroupMembers={shouldNeverBeCalled}
|
||||
startNewConversationFromPhoneNumber={
|
||||
shouldNeverBeCalled
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions */
|
||||
) : (
|
||||
<div className="module-ForwardMessageModal__main-body">
|
||||
<SearchInput
|
||||
disabled={candidateConversations.length === 0}
|
||||
placeholder={i18n('contactSearchPlaceholder')}
|
||||
onChange={event => {
|
||||
setSearchTerm(event.target.value);
|
||||
}}
|
||||
</Measure>
|
||||
) : (
|
||||
<div className="module-ForwardMessageModal__no-candidate-contacts">
|
||||
{i18n('noContactsFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="module-ForwardMessageModal__footer">
|
||||
<div>
|
||||
{Boolean(selectedContacts.length) &&
|
||||
selectedContacts.map(contact => contact.title).join(', ')}
|
||||
</div>
|
||||
<div>
|
||||
{isEditingMessage || !isMessageEditable ? (
|
||||
<Button
|
||||
aria-label={i18n('ForwardMessageModal--continue')}
|
||||
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
|
||||
disabled={!canForwardMessage}
|
||||
onClick={forwardMessage}
|
||||
ref={inputRef}
|
||||
value={searchTerm}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={i18n('forwardMessage')}
|
||||
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--continue"
|
||||
disabled={!hasContactsSelected}
|
||||
onClick={() => setIsEditingMessage(true)}
|
||||
/>
|
||||
)}
|
||||
{candidateConversations.length ? (
|
||||
<Measure bounds>
|
||||
{({ contentRect, measureRef }: MeasuredComponentProps) => {
|
||||
// We disable this ESLint rule because we're capturing a bubbled
|
||||
// keydown event. See [this note in the jsx-a11y docs][0].
|
||||
//
|
||||
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
return (
|
||||
<div
|
||||
className="module-ForwardMessageModal__list-wrapper"
|
||||
ref={measureRef}
|
||||
>
|
||||
<ConversationList
|
||||
dimensions={contentRect.bounds}
|
||||
getRow={getRow}
|
||||
i18n={i18n}
|
||||
onClickArchiveButton={shouldNeverBeCalled}
|
||||
onClickContactCheckbox={(
|
||||
conversationId: string,
|
||||
disabledReason:
|
||||
| undefined
|
||||
| ContactCheckboxDisabledReason
|
||||
) => {
|
||||
if (
|
||||
disabledReason !==
|
||||
ContactCheckboxDisabledReason.MaximumContactsSelected
|
||||
) {
|
||||
toggleSelectedConversation(conversationId);
|
||||
}
|
||||
}}
|
||||
onSelectConversation={shouldNeverBeCalled}
|
||||
renderMessageSearchResult={() => {
|
||||
shouldNeverBeCalled();
|
||||
return <div />;
|
||||
}}
|
||||
rowCount={rowCount}
|
||||
shouldRecomputeRowHeights={false}
|
||||
showChooseGroupMembers={shouldNeverBeCalled}
|
||||
startNewConversationFromPhoneNumber={
|
||||
shouldNeverBeCalled
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions */
|
||||
}}
|
||||
</Measure>
|
||||
) : (
|
||||
<div className="module-ForwardMessageModal__no-candidate-contacts">
|
||||
{i18n('noContactsFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="module-ForwardMessageModal__footer">
|
||||
<div>
|
||||
{Boolean(selectedContacts.length) &&
|
||||
selectedContacts.map(contact => contact.title).join(', ')}
|
||||
</div>
|
||||
<div>
|
||||
{isEditingMessage || !isMessageEditable ? (
|
||||
<Button
|
||||
aria-label={i18n('ForwardMessageModal--continue')}
|
||||
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
|
||||
disabled={!canForwardMessage}
|
||||
onClick={forwardMessage}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={i18n('forwardMessage')}
|
||||
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--continue"
|
||||
disabled={!hasContactsSelected}
|
||||
onClick={() => setIsEditingMessage(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalHost>
|
||||
</ModalHost>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -41,6 +41,8 @@ export type PropsDataType = {
|
|||
} & Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'announcementsOnly'
|
||||
| 'areWeAdmin'
|
||||
| 'avatarPath'
|
||||
| 'canChangeTimer'
|
||||
| 'color'
|
||||
|
@ -291,6 +293,8 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
|
|||
|
||||
private renderOutgoingCallButtons(): ReactNode {
|
||||
const {
|
||||
announcementsOnly,
|
||||
areWeAdmin,
|
||||
i18n,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
|
@ -301,15 +305,18 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
|
|||
|
||||
const videoButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOutgoingVideoCallInConversation}
|
||||
aria-label={i18n('makeOutgoingVideoCall')}
|
||||
className={classNames(
|
||||
'module-ConversationHeader__button',
|
||||
'module-ConversationHeader__button--video',
|
||||
showBackButton ? null : 'module-ConversationHeader__button--show'
|
||||
showBackButton ? null : 'module-ConversationHeader__button--show',
|
||||
!showBackButton && announcementsOnly && !areWeAdmin
|
||||
? 'module-ConversationHeader__button--show-disabled'
|
||||
: undefined
|
||||
)}
|
||||
disabled={showBackButton}
|
||||
aria-label={i18n('makeOutgoingVideoCall')}
|
||||
onClick={onOutgoingVideoCallInConversation}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -341,14 +348,14 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
|
|||
return (
|
||||
<button
|
||||
aria-label={i18n('joinOngoingCall')}
|
||||
type="button"
|
||||
onClick={onOutgoingVideoCallInConversation}
|
||||
className={classNames(
|
||||
'module-ConversationHeader__button',
|
||||
'module-ConversationHeader__button--join-call',
|
||||
showBackButton ? null : 'module-ConversationHeader__button--show'
|
||||
)}
|
||||
disabled={showBackButton}
|
||||
onClick={onOutgoingVideoCallInConversation}
|
||||
type="button"
|
||||
>
|
||||
{isNarrow ? null : i18n('joinOngoingCall')}
|
||||
</button>
|
||||
|
|
|
@ -1383,4 +1383,62 @@ storiesOf('Components/Conversation/GroupV2Change', module)
|
|||
)}
|
||||
</>
|
||||
);
|
||||
})
|
||||
.add('Announcement Group (Change)', () => {
|
||||
return (
|
||||
<>
|
||||
{renderChange({
|
||||
from: OUR_ID,
|
||||
details: [
|
||||
{
|
||||
type: 'announcements-only',
|
||||
announcementsOnly: true,
|
||||
},
|
||||
],
|
||||
})}
|
||||
{renderChange({
|
||||
from: ADMIN_A,
|
||||
details: [
|
||||
{
|
||||
type: 'announcements-only',
|
||||
announcementsOnly: true,
|
||||
},
|
||||
],
|
||||
})}
|
||||
{renderChange({
|
||||
details: [
|
||||
{
|
||||
type: 'announcements-only',
|
||||
announcementsOnly: true,
|
||||
},
|
||||
],
|
||||
})}
|
||||
{renderChange({
|
||||
from: OUR_ID,
|
||||
details: [
|
||||
{
|
||||
type: 'announcements-only',
|
||||
announcementsOnly: false,
|
||||
},
|
||||
],
|
||||
})}
|
||||
{renderChange({
|
||||
from: ADMIN_A,
|
||||
details: [
|
||||
{
|
||||
type: 'announcements-only',
|
||||
announcementsOnly: false,
|
||||
},
|
||||
],
|
||||
})}
|
||||
{renderChange({
|
||||
details: [
|
||||
{
|
||||
type: 'announcements-only',
|
||||
announcementsOnly: false,
|
||||
},
|
||||
],
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -139,3 +139,15 @@ story.add('Group Links On', () => {
|
|||
|
||||
return <ConversationDetails {...props} isAdmin />;
|
||||
});
|
||||
|
||||
story.add('Group add with missing capabilities', () => (
|
||||
<ConversationDetails
|
||||
{...createProps()}
|
||||
canEditGroupInfo
|
||||
addMembers={async () => {
|
||||
const error = new Error();
|
||||
error.code = 'E_NO_CAPABILITY';
|
||||
throw error;
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -30,6 +30,7 @@ import {
|
|||
import { EditConversationAttributesModal } from './EditConversationAttributesModal';
|
||||
import { RequestState } from './util';
|
||||
import { getCustomColorStyle } from '../../../util/getCustomColorStyle';
|
||||
import { ConfirmationDialog } from '../../ConfirmationDialog';
|
||||
|
||||
enum ModalState {
|
||||
NothingOpen,
|
||||
|
@ -109,6 +110,9 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
addGroupMembersRequestState,
|
||||
setAddGroupMembersRequestState,
|
||||
] = useState<RequestState>(RequestState.Inactive);
|
||||
const [membersMissingCapability, setMembersMissingCapability] = useState(
|
||||
false
|
||||
);
|
||||
|
||||
if (conversation === undefined) {
|
||||
throw new Error('ConversationDetails rendered without a conversation');
|
||||
|
@ -194,7 +198,12 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
setModalState(ModalState.NothingOpen);
|
||||
setAddGroupMembersRequestState(RequestState.Inactive);
|
||||
} catch (err) {
|
||||
setAddGroupMembersRequestState(RequestState.InactiveWithError);
|
||||
if (err.code === 'E_NO_CAPABILITY') {
|
||||
setMembersMissingCapability(true);
|
||||
setAddGroupMembersRequestState(RequestState.InactiveWithError);
|
||||
} else {
|
||||
setAddGroupMembersRequestState(RequestState.InactiveWithError);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
|
@ -211,6 +220,16 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
|
||||
return (
|
||||
<div className="conversation-details-panel">
|
||||
{membersMissingCapability && (
|
||||
<ConfirmationDialog
|
||||
cancelText={i18n('Confirmation--confirm')}
|
||||
i18n={i18n}
|
||||
onClose={() => setMembersMissingCapability(false)}
|
||||
>
|
||||
{i18n('GroupV2--add--missing-capability')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
<ConversationDetailsHeader
|
||||
canEdit={canEditGroupInfo}
|
||||
conversation={conversation}
|
||||
|
|
|
@ -27,6 +27,8 @@ const conversation: ConversationType = getDefaultConversation({
|
|||
title: 'Some Conversation',
|
||||
type: 'group',
|
||||
sharedGroupNames: [],
|
||||
announcementsOnlyReady: true,
|
||||
areWeAdmin: true,
|
||||
});
|
||||
|
||||
const createProps = (): PropsType => ({
|
||||
|
@ -36,6 +38,7 @@ const createProps = (): PropsType => ({
|
|||
'setAccessControlAttributesSetting'
|
||||
),
|
||||
setAccessControlMembersSetting: action('setAccessControlMembersSetting'),
|
||||
setAnnouncementsOnly: action('setAnnouncementsOnly'),
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
|
|
|
@ -6,6 +6,7 @@ import React from 'react';
|
|||
import { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
import { getAccessControlOptions } from '../../../util/getAccessControlOptions';
|
||||
import { SignalService as Proto } from '../../../protobuf';
|
||||
|
||||
import { PanelRow } from './PanelRow';
|
||||
import { PanelSection } from './PanelSection';
|
||||
|
@ -16,14 +17,16 @@ export type PropsType = {
|
|||
i18n: LocalizerType;
|
||||
setAccessControlAttributesSetting: (value: number) => void;
|
||||
setAccessControlMembersSetting: (value: number) => void;
|
||||
setAnnouncementsOnly: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const GroupV2Permissions: React.ComponentType<PropsType> = ({
|
||||
export const GroupV2Permissions = ({
|
||||
conversation,
|
||||
i18n,
|
||||
setAccessControlAttributesSetting,
|
||||
setAccessControlMembersSetting,
|
||||
}) => {
|
||||
setAnnouncementsOnly,
|
||||
}: PropsType): JSX.Element => {
|
||||
if (conversation === undefined) {
|
||||
throw new Error('GroupV2Permissions rendered without a conversation');
|
||||
}
|
||||
|
@ -34,7 +37,16 @@ export const GroupV2Permissions: React.ComponentType<PropsType> = ({
|
|||
const updateAccessControlMembers = (value: string) => {
|
||||
setAccessControlMembersSetting(Number(value));
|
||||
};
|
||||
const AccessControlEnum = Proto.AccessControl.AccessRequired;
|
||||
const updateAnnouncementsOnly = (value: string) => {
|
||||
setAnnouncementsOnly(Number(value) === AccessControlEnum.ADMINISTRATOR);
|
||||
};
|
||||
const accessControlOptions = getAccessControlOptions(i18n);
|
||||
const announcementsOnlyValue = String(
|
||||
conversation.announcementsOnly
|
||||
? AccessControlEnum.ADMINISTRATOR
|
||||
: AccessControlEnum.MEMBER
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelSection>
|
||||
|
@ -60,6 +72,19 @@ export const GroupV2Permissions: React.ComponentType<PropsType> = ({
|
|||
/>
|
||||
}
|
||||
/>
|
||||
{conversation.areWeAdmin && conversation.announcementsOnlyReady && (
|
||||
<PanelRow
|
||||
label={i18n('ConversationDetails--announcement-label')}
|
||||
info={i18n('ConversationDetails--announcement-info')}
|
||||
right={
|
||||
<Select
|
||||
onChange={updateAnnouncementsOnly}
|
||||
options={accessControlOptions}
|
||||
value={announcementsOnlyValue}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -850,6 +850,29 @@ export function renderChangeDetail(
|
|||
}
|
||||
return renderString('GroupV2--description--change--unknown', i18n);
|
||||
}
|
||||
if (detail.type === 'announcements-only') {
|
||||
if (detail.announcementsOnly) {
|
||||
if (fromYou) {
|
||||
return renderString('GroupV2--announcements--admin--you', i18n);
|
||||
}
|
||||
if (from) {
|
||||
return renderString('GroupV2--announcements--admin--other', i18n, [
|
||||
renderContact(from),
|
||||
]);
|
||||
}
|
||||
return renderString('GroupV2--announcements--admin--unknown', i18n);
|
||||
}
|
||||
|
||||
if (fromYou) {
|
||||
return renderString('GroupV2--announcements--member--you', i18n);
|
||||
}
|
||||
if (from) {
|
||||
return renderString('GroupV2--announcements--member--other', i18n, [
|
||||
renderContact(from),
|
||||
]);
|
||||
}
|
||||
return renderString('GroupV2--announcements--member--unknown', i18n);
|
||||
}
|
||||
|
||||
throw missingCaseError(detail);
|
||||
}
|
||||
|
|
70
ts/groups.ts
70
ts/groups.ts
|
@ -78,98 +78,102 @@ import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
|||
|
||||
export { joinViaLink } from './groups/joinViaLink';
|
||||
|
||||
export type GroupV2AccessCreateChangeType = {
|
||||
type GroupV2AccessCreateChangeType = {
|
||||
type: 'create';
|
||||
};
|
||||
export type GroupV2AccessAttributesChangeType = {
|
||||
type GroupV2AccessAttributesChangeType = {
|
||||
type: 'access-attributes';
|
||||
newPrivilege: number;
|
||||
};
|
||||
export type GroupV2AccessMembersChangeType = {
|
||||
type GroupV2AccessMembersChangeType = {
|
||||
type: 'access-members';
|
||||
newPrivilege: number;
|
||||
};
|
||||
export type GroupV2AccessInviteLinkChangeType = {
|
||||
type GroupV2AccessInviteLinkChangeType = {
|
||||
type: 'access-invite-link';
|
||||
newPrivilege: number;
|
||||
};
|
||||
export type GroupV2AvatarChangeType = {
|
||||
type GroupV2AnnouncementsOnlyChangeType = {
|
||||
type: 'announcements-only';
|
||||
announcementsOnly: boolean;
|
||||
};
|
||||
type GroupV2AvatarChangeType = {
|
||||
type: 'avatar';
|
||||
removed: boolean;
|
||||
};
|
||||
export type GroupV2TitleChangeType = {
|
||||
type GroupV2TitleChangeType = {
|
||||
type: 'title';
|
||||
// Allow for null, because the title could be removed entirely
|
||||
newTitle?: string;
|
||||
};
|
||||
export type GroupV2GroupLinkAddChangeType = {
|
||||
type GroupV2GroupLinkAddChangeType = {
|
||||
type: 'group-link-add';
|
||||
privilege: number;
|
||||
};
|
||||
export type GroupV2GroupLinkResetChangeType = {
|
||||
type GroupV2GroupLinkResetChangeType = {
|
||||
type: 'group-link-reset';
|
||||
};
|
||||
export type GroupV2GroupLinkRemoveChangeType = {
|
||||
type GroupV2GroupLinkRemoveChangeType = {
|
||||
type: 'group-link-remove';
|
||||
};
|
||||
|
||||
// No disappearing messages timer change type - message.expirationTimerUpdate used instead
|
||||
|
||||
export type GroupV2MemberAddChangeType = {
|
||||
type GroupV2MemberAddChangeType = {
|
||||
type: 'member-add';
|
||||
conversationId: string;
|
||||
};
|
||||
export type GroupV2MemberAddFromInviteChangeType = {
|
||||
type GroupV2MemberAddFromInviteChangeType = {
|
||||
type: 'member-add-from-invite';
|
||||
conversationId: string;
|
||||
inviter?: string;
|
||||
};
|
||||
export type GroupV2MemberAddFromLinkChangeType = {
|
||||
type GroupV2MemberAddFromLinkChangeType = {
|
||||
type: 'member-add-from-link';
|
||||
conversationId: string;
|
||||
};
|
||||
export type GroupV2MemberAddFromAdminApprovalChangeType = {
|
||||
type GroupV2MemberAddFromAdminApprovalChangeType = {
|
||||
type: 'member-add-from-admin-approval';
|
||||
conversationId: string;
|
||||
};
|
||||
export type GroupV2MemberPrivilegeChangeType = {
|
||||
type GroupV2MemberPrivilegeChangeType = {
|
||||
type: 'member-privilege';
|
||||
conversationId: string;
|
||||
newPrivilege: number;
|
||||
};
|
||||
export type GroupV2MemberRemoveChangeType = {
|
||||
type GroupV2MemberRemoveChangeType = {
|
||||
type: 'member-remove';
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
export type GroupV2PendingAddOneChangeType = {
|
||||
type GroupV2PendingAddOneChangeType = {
|
||||
type: 'pending-add-one';
|
||||
conversationId: string;
|
||||
};
|
||||
export type GroupV2PendingAddManyChangeType = {
|
||||
type GroupV2PendingAddManyChangeType = {
|
||||
type: 'pending-add-many';
|
||||
count: number;
|
||||
};
|
||||
// Note: pending-remove is only used if user didn't also join the group at the same time
|
||||
export type GroupV2PendingRemoveOneChangeType = {
|
||||
type GroupV2PendingRemoveOneChangeType = {
|
||||
type: 'pending-remove-one';
|
||||
conversationId: string;
|
||||
inviter?: string;
|
||||
};
|
||||
// Note: pending-remove is only used if user didn't also join the group at the same time
|
||||
export type GroupV2PendingRemoveManyChangeType = {
|
||||
type GroupV2PendingRemoveManyChangeType = {
|
||||
type: 'pending-remove-many';
|
||||
count: number;
|
||||
inviter?: string;
|
||||
};
|
||||
|
||||
export type GroupV2AdminApprovalAddOneChangeType = {
|
||||
type GroupV2AdminApprovalAddOneChangeType = {
|
||||
type: 'admin-approval-add-one';
|
||||
conversationId: string;
|
||||
};
|
||||
// Note: admin-approval-remove-one is only used if user didn't also join the group at
|
||||
// the same time
|
||||
export type GroupV2AdminApprovalRemoveOneChangeType = {
|
||||
type GroupV2AdminApprovalRemoveOneChangeType = {
|
||||
type: 'admin-approval-remove-one';
|
||||
conversationId: string;
|
||||
inviter?: string;
|
||||
|
@ -188,6 +192,7 @@ export type GroupV2ChangeDetailType =
|
|||
| GroupV2AccessMembersChangeType
|
||||
| GroupV2AdminApprovalAddOneChangeType
|
||||
| GroupV2AdminApprovalRemoveOneChangeType
|
||||
| GroupV2AnnouncementsOnlyChangeType
|
||||
| GroupV2AvatarChangeType
|
||||
| GroupV2DescriptionChangeType
|
||||
| GroupV2GroupLinkAddChangeType
|
||||
|
@ -901,6 +906,20 @@ export function buildAccessControlAddFromInviteLinkChange(
|
|||
return actions;
|
||||
}
|
||||
|
||||
export function buildAnnouncementsOnlyChange(
|
||||
group: ConversationAttributesType,
|
||||
value: boolean
|
||||
): Proto.GroupChange.Actions {
|
||||
const action = new Proto.GroupChange.Actions.ModifyAnnouncementsOnlyAction();
|
||||
action.announcementsOnly = value;
|
||||
|
||||
const actions = new Proto.GroupChange.Actions();
|
||||
actions.version = (group.revision || 0) + 1;
|
||||
actions.modifyAnnouncementsOnly = action;
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function buildAccessControlAttributesChange(
|
||||
group: ConversationAttributesType,
|
||||
value: AccessRequiredEnum
|
||||
|
@ -3876,6 +3895,15 @@ function extractDiffs({
|
|||
});
|
||||
});
|
||||
|
||||
// announcementsOnly
|
||||
|
||||
if (old.announcementsOnly !== current.announcementsOnly) {
|
||||
details.push({
|
||||
type: 'announcements-only',
|
||||
announcementsOnly: Boolean(current.announcementsOnly),
|
||||
});
|
||||
}
|
||||
|
||||
// final processing
|
||||
|
||||
let message: MessageAttributesType | undefined;
|
||||
|
|
1
ts/model-types.d.ts
vendored
1
ts/model-types.d.ts
vendored
|
@ -290,6 +290,7 @@ export type ConversationAttributesType = {
|
|||
members: AccessRequiredEnum;
|
||||
addFromInviteLink: AccessRequiredEnum;
|
||||
};
|
||||
announcementsOnly?: boolean;
|
||||
avatar?: {
|
||||
url: string;
|
||||
path: string;
|
||||
|
|
|
@ -541,6 +541,16 @@ export class ConversationModel extends window.Backbone
|
|||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.get('announcementsOnly') &&
|
||||
!toRequest.get('capabilities')?.announcementGroup
|
||||
) {
|
||||
window.log.warn(
|
||||
`addPendingApprovalRequest/${idLog}: member needs to upgrade.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// We need the user's profileKeyCredential, which requires a roundtrip with the
|
||||
// server, and most definitely their profileKey. A getProfiles() call will
|
||||
// ensure that we have as much as we can get with the data we have.
|
||||
|
@ -585,6 +595,16 @@ export class ConversationModel extends window.Backbone
|
|||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.get('announcementsOnly') &&
|
||||
!toRequest.get('capabilities')?.announcementGroup
|
||||
) {
|
||||
window.log.warn(
|
||||
`addMember/${idLog}: ${conversationId} needs to upgrade.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// We need the user's profileKeyCredential, which requires a roundtrip with the
|
||||
// server, and most definitely their profileKey. A getProfiles() call will
|
||||
// ensure that we have as much as we can get with the data we have.
|
||||
|
@ -1436,6 +1456,8 @@ export class ConversationModel extends window.Backbone
|
|||
?.addFromInviteLink,
|
||||
accessControlAttributes: this.get('accessControl')?.attributes,
|
||||
accessControlMembers: this.get('accessControl')?.members,
|
||||
announcementsOnly: Boolean(this.get('announcementsOnly')),
|
||||
announcementsOnlyReady: this.canBeAnnouncementGroup(),
|
||||
expireTimer: this.get('expireTimer'),
|
||||
muteExpiresAt: this.get('muteExpiresAt')!,
|
||||
name: this.get('name')!,
|
||||
|
@ -1830,6 +1852,20 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
|
||||
async addMembersV2(conversationIds: ReadonlyArray<string>): Promise<void> {
|
||||
if (this.get('announcementsOnly')) {
|
||||
const isEveryMemberCapable = conversationIds.every(conversationId => {
|
||||
const model = window.ConversationController.get(conversationId);
|
||||
return Boolean(model?.get('capabilities')?.announcementGroup);
|
||||
});
|
||||
if (!isEveryMemberCapable) {
|
||||
const error = new Error(
|
||||
'addMembersV2: some or all members need to upgrade.'
|
||||
);
|
||||
error.code = 'E_NO_CAPABILITY';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await this.modifyGroupV2({
|
||||
name: 'addMembersV2',
|
||||
createGroupChange: () =>
|
||||
|
@ -2975,6 +3011,17 @@ export class ConversationModel extends window.Backbone
|
|||
);
|
||||
}
|
||||
|
||||
canBeAnnouncementGroup(): boolean {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const members = getConversationMembers(this.attributes);
|
||||
return members.every(conversationAttrs =>
|
||||
Boolean(conversationAttrs.capabilities?.announcementGroup)
|
||||
);
|
||||
}
|
||||
|
||||
getMemberIds(): Array<string> {
|
||||
const members = this.getMembers();
|
||||
return members.map(member => member.id);
|
||||
|
@ -4004,6 +4051,23 @@ export class ConversationModel extends window.Backbone
|
|||
});
|
||||
}
|
||||
|
||||
async updateAnnouncementsOnly(value: boolean): Promise<void> {
|
||||
if (!isGroupV2(this.attributes) || !this.canBeAnnouncementGroup()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.modifyGroupV2({
|
||||
name: 'updateAnnouncementsOnly',
|
||||
createGroupChange: async () =>
|
||||
window.Signal.Groups.buildAnnouncementsOnlyChange(
|
||||
this.attributes,
|
||||
value
|
||||
),
|
||||
});
|
||||
|
||||
this.set({ announcementsOnly: value });
|
||||
}
|
||||
|
||||
async updateExpirationTimer(
|
||||
providedExpireTimer: number | undefined,
|
||||
providedSource?: unknown,
|
||||
|
@ -5150,6 +5214,12 @@ export class ConversationModel extends window.Backbone
|
|||
return;
|
||||
}
|
||||
|
||||
// Drop typing indicators for announcement only groups where the sender
|
||||
// is not an admin
|
||||
if (this.get('announcementsOnly') && !this.isAdmin(senderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typingToken = `${senderId}.${senderDevice}`;
|
||||
|
||||
this.contactTypingTimers = this.contactTypingTimers || {};
|
||||
|
|
|
@ -2737,6 +2737,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
return;
|
||||
}
|
||||
|
||||
// Drop incoming messages to announcement only groups where sender is not admin
|
||||
if (
|
||||
conversation.get('announcementsOnly') &&
|
||||
!conversation.isAdmin(senderId)
|
||||
) {
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = window.getGuid();
|
||||
|
||||
// Send delivery receipts, but only for incoming sealed sender messages
|
||||
|
|
|
@ -121,6 +121,8 @@ export type ConversationType = {
|
|||
accessControlAddFromInviteLink?: number;
|
||||
accessControlAttributes?: number;
|
||||
accessControlMembers?: number;
|
||||
announcementsOnly?: boolean;
|
||||
announcementsOnlyReady?: boolean;
|
||||
expireTimer?: number;
|
||||
memberships?: Array<{
|
||||
conversationId: string;
|
||||
|
|
|
@ -26,6 +26,7 @@ import { isConversationUnregistered } from '../../util/isConversationUnregistere
|
|||
import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConversations';
|
||||
import { ContactNameColors, ContactNameColorType } from '../../types/Colors';
|
||||
import { isInSystemContacts } from '../../util/isInSystemContacts';
|
||||
import { isGroupV2 } from '../../util/whatTypeOfConversation';
|
||||
|
||||
import {
|
||||
getIntl,
|
||||
|
@ -894,3 +895,32 @@ export function isMissingRequiredProfileSharing(
|
|||
conversation.messageCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
export const getGroupAdminsSelector = createSelector(
|
||||
getConversationSelector,
|
||||
(conversationSelector: GetConversationByIdType) => {
|
||||
return (conversationId: string): Array<ConversationType> => {
|
||||
const { groupId, groupVersion, memberships = [] } = conversationSelector(
|
||||
conversationId
|
||||
);
|
||||
|
||||
if (
|
||||
!isGroupV2({
|
||||
groupId,
|
||||
groupVersion,
|
||||
})
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const admins: Array<ConversationType> = [];
|
||||
memberships.forEach(membership => {
|
||||
if (membership.isAdmin) {
|
||||
const admin = conversationSelector(membership.conversationId);
|
||||
admins.push(admin);
|
||||
}
|
||||
});
|
||||
return admins;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1133,6 +1133,11 @@ export function canReply(
|
|||
return false;
|
||||
}
|
||||
|
||||
// Groups where only admins can send messages
|
||||
if (conversation.announcementsOnly && !conversation.areWeAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We can reply if this is outgoing and sent to at least one recipient
|
||||
if (isOutgoing(message)) {
|
||||
return (
|
||||
|
|
|
@ -12,6 +12,7 @@ import { selectRecentEmojis } from '../selectors/emojis';
|
|||
import { getIntl, getUserConversationId } from '../selectors/user';
|
||||
import {
|
||||
getConversationSelector,
|
||||
getGroupAdminsSelector,
|
||||
isMissingRequiredProfileSharing,
|
||||
} from '../selectors/conversations';
|
||||
import { getPropsForQuote } from '../selectors/message';
|
||||
|
@ -38,7 +39,12 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
throw new Error(`Conversation id ${id} not found!`);
|
||||
}
|
||||
|
||||
const { draftText, draftBodyRanges } = conversation;
|
||||
const {
|
||||
announcementsOnly,
|
||||
areWeAdmin,
|
||||
draftText,
|
||||
draftBodyRanges,
|
||||
} = conversation;
|
||||
|
||||
const receivedPacks = getReceivedStickerPacks(state);
|
||||
const installedPacks = getInstalledStickerPacks(state);
|
||||
|
@ -109,6 +115,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
isMissingMandatoryProfileSharing: isMissingRequiredProfileSharing(
|
||||
conversation
|
||||
),
|
||||
// Groups
|
||||
announcementsOnly,
|
||||
areWeAdmin,
|
||||
groupAdmins: getGroupAdminsSelector(state)(conversation.id),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -93,10 +93,13 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
|
|||
return {
|
||||
...pick(conversation, [
|
||||
'acceptedMessageRequest',
|
||||
'announcementsOnly',
|
||||
'areWeAdmin',
|
||||
'avatarPath',
|
||||
'canChangeTimer',
|
||||
'color',
|
||||
'expireTimer',
|
||||
'groupVersion',
|
||||
'isArchived',
|
||||
'isMe',
|
||||
'isPinned',
|
||||
|
@ -107,10 +110,9 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
|
|||
'name',
|
||||
'phoneNumber',
|
||||
'profileName',
|
||||
'sharedGroupNames',
|
||||
'title',
|
||||
'type',
|
||||
'groupVersion',
|
||||
'sharedGroupNames',
|
||||
'unblurredAvatarPath',
|
||||
]),
|
||||
conversationTitle: state.conversations.selectedConversationTitle,
|
||||
|
|
|
@ -15,6 +15,7 @@ export type SmartGroupV2PermissionsProps = {
|
|||
conversationId: string;
|
||||
setAccessControlAttributesSetting: (value: number) => void;
|
||||
setAccessControlMembersSetting: (value: number) => void;
|
||||
setAnnouncementsOnly: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const mapStateToProps = (
|
||||
|
|
|
@ -892,11 +892,13 @@ export type WebAPIConnectType = {
|
|||
};
|
||||
|
||||
export type CapabilitiesType = {
|
||||
announcementGroup: boolean;
|
||||
gv2: boolean;
|
||||
'gv1-migration': boolean;
|
||||
senderKey: boolean;
|
||||
};
|
||||
export type CapabilitiesUploadType = {
|
||||
announcementGroup: boolean;
|
||||
'gv2-3': boolean;
|
||||
'gv1-migration': boolean;
|
||||
senderKey: boolean;
|
||||
|
@ -1558,6 +1560,7 @@ export function initialize({
|
|||
options: { accessKey?: ArrayBuffer } = {}
|
||||
) {
|
||||
const capabilities: CapabilitiesUploadType = {
|
||||
announcementGroup: true,
|
||||
'gv2-3': true,
|
||||
'gv1-migration': true,
|
||||
senderKey: false,
|
||||
|
|
|
@ -25,7 +25,7 @@ export function isMe(conversationAttrs: ConversationAttributesType): boolean {
|
|||
}
|
||||
|
||||
export function isGroupV1(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
conversationAttrs: Pick<ConversationAttributesType, 'groupId'>
|
||||
): boolean {
|
||||
const { groupId } = conversationAttrs;
|
||||
if (!groupId) {
|
||||
|
@ -37,7 +37,10 @@ export function isGroupV1(
|
|||
}
|
||||
|
||||
export function isGroupV2(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
conversationAttrs: Pick<
|
||||
ConversationAttributesType,
|
||||
'groupId' | 'groupVersion'
|
||||
>
|
||||
): boolean {
|
||||
const { groupId, groupVersion = 0 } = conversationAttrs;
|
||||
if (!groupId) {
|
||||
|
|
|
@ -335,6 +335,10 @@ Whisper.UnableToLoadToast = Whisper.ToastView.extend({
|
|||
},
|
||||
});
|
||||
|
||||
Whisper.CannotStartGroupCallToast = Whisper.ToastView.extend({
|
||||
template: () => window.i18n('GroupV2--cannot-start-group-call'),
|
||||
});
|
||||
|
||||
Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({
|
||||
template: () => window.i18n('dangerousFileType'),
|
||||
});
|
||||
|
@ -555,6 +559,11 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
);
|
||||
const isVideoCall = true;
|
||||
|
||||
if (model.get('announcementsOnly') && !model.areWeAdmin()) {
|
||||
this.showToast(Whisper.CannotStartGroupCallToast);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.isCallSafe()) {
|
||||
window.log.info(
|
||||
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
|
||||
|
@ -722,6 +731,8 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
this.disableLinkPreviews = true;
|
||||
this.removeLinkPreview();
|
||||
},
|
||||
|
||||
openConversation: this.openConversation.bind(this),
|
||||
};
|
||||
|
||||
this.compositionAreaView = new Whisper.ReactWrapperView({
|
||||
|
@ -733,7 +744,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
});
|
||||
|
||||
// Finally, add it to the DOM
|
||||
this.$('.composition-area-placeholder').append(this.compositionAreaView.el);
|
||||
this.$('.CompositionArea__placeholder').append(this.compositionAreaView.el);
|
||||
},
|
||||
|
||||
async longRunningTaskWrapper<T>({
|
||||
|
@ -2316,17 +2327,24 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
includedAttachments?: Array<AttachmentType>,
|
||||
linkPreview?: LinkPreviewType
|
||||
) => {
|
||||
const didForwardSuccessfully = await this.maybeForwardMessage(
|
||||
message,
|
||||
conversationIds,
|
||||
messageBody,
|
||||
includedAttachments,
|
||||
linkPreview
|
||||
);
|
||||
try {
|
||||
const didForwardSuccessfully = await this.maybeForwardMessage(
|
||||
message,
|
||||
conversationIds,
|
||||
messageBody,
|
||||
includedAttachments,
|
||||
linkPreview
|
||||
);
|
||||
|
||||
if (didForwardSuccessfully) {
|
||||
this.forwardMessageModal.remove();
|
||||
this.forwardMessageModal = null;
|
||||
if (didForwardSuccessfully) {
|
||||
this.forwardMessageModal.remove();
|
||||
this.forwardMessageModal = null;
|
||||
}
|
||||
} catch (err) {
|
||||
window.log.warn(
|
||||
'doForwardMessage',
|
||||
err && err.stack ? err.stack : err
|
||||
);
|
||||
}
|
||||
},
|
||||
isSticker: Boolean(message.get('sticker')),
|
||||
|
@ -2380,6 +2398,14 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
window.ConversationController.get(id)
|
||||
);
|
||||
|
||||
const cannotSend = conversations.some(
|
||||
conversation =>
|
||||
conversation?.get('announcementsOnly') && !conversation.areWeAdmin()
|
||||
);
|
||||
if (!cannotSend) {
|
||||
throw new Error('Cannot send to group');
|
||||
}
|
||||
|
||||
// Verify that all contacts that we're forwarding
|
||||
// to are verified and trusted
|
||||
const unverifiedContacts: Array<ConversationModel> = [];
|
||||
|
@ -3253,6 +3279,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
setAccessControlMembersSetting: this.setAccessControlMembersSetting.bind(
|
||||
this
|
||||
),
|
||||
setAnnouncementsOnly: this.setAnnouncementsOnly.bind(this),
|
||||
}
|
||||
),
|
||||
});
|
||||
|
@ -3301,6 +3328,10 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
},
|
||||
|
||||
showConversationDetails() {
|
||||
// Run a getProfiles in case member's capabilities have changed
|
||||
// Redux should cover us on the return here so no need to await this.
|
||||
this.model.throttledGetProfiles();
|
||||
|
||||
const { model }: { model: ConversationModel } = this;
|
||||
|
||||
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
|
||||
|
@ -3593,7 +3624,9 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
});
|
||||
},
|
||||
|
||||
async setAccessControlAddFromInviteLinkSetting(value: boolean) {
|
||||
async setAccessControlAddFromInviteLinkSetting(
|
||||
value: boolean
|
||||
): Promise<void> {
|
||||
const { model }: { model: ConversationModel } = this;
|
||||
|
||||
await this.longRunningTaskWrapper({
|
||||
|
@ -3602,7 +3635,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
});
|
||||
},
|
||||
|
||||
async setAccessControlAttributesSetting(value: number) {
|
||||
async setAccessControlAttributesSetting(value: number): Promise<void> {
|
||||
const { model }: { model: ConversationModel } = this;
|
||||
|
||||
await this.longRunningTaskWrapper({
|
||||
|
@ -3611,7 +3644,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
});
|
||||
},
|
||||
|
||||
async setAccessControlMembersSetting(value: number) {
|
||||
async setAccessControlMembersSetting(value: number): Promise<void> {
|
||||
const { model }: { model: ConversationModel } = this;
|
||||
|
||||
await this.longRunningTaskWrapper({
|
||||
|
@ -3620,6 +3653,15 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
});
|
||||
},
|
||||
|
||||
async setAnnouncementsOnly(value: boolean): Promise<void> {
|
||||
const { model }: { model: ConversationModel } = this;
|
||||
|
||||
await this.longRunningTaskWrapper({
|
||||
name: 'updateAnnouncementsOnly',
|
||||
task: async () => model.updateAnnouncementsOnly(value),
|
||||
});
|
||||
},
|
||||
|
||||
async destroyMessages() {
|
||||
const { model }: { model: ConversationModel } = this;
|
||||
|
||||
|
|
1
ts/window.d.ts
vendored
1
ts/window.d.ts
vendored
|
@ -638,6 +638,7 @@ export type WhisperType = {
|
|||
CannotMixImageAndNonImageAttachmentsToast: typeof window.Whisper.ToastView;
|
||||
CaptchaSolvedToast: typeof window.Whisper.ToastView;
|
||||
CaptchaFailedToast: typeof window.Whisper.ToastView;
|
||||
CannotStartGroupCallToast: typeof window.Whisper.ToastView;
|
||||
DangerousFileTypeToast: typeof window.Whisper.ToastView;
|
||||
DecryptionErrorToast: typeof window.Whisper.ToastView;
|
||||
ExpiredToast: typeof window.Whisper.ToastView;
|
||||
|
|
Loading…
Reference in a new issue