From 56d5d283bd6cbb2dec8fbde1cd6f9f0faf035e75 Mon Sep 17 00:00:00 2001
From: Josh Perez <60019601+josh-signal@users.noreply.github.com>
Date: Tue, 20 Jul 2021 16:18:35 -0400
Subject: [PATCH] Support for announcement-only groups
---
_locales/en/messages.json | 75 ++++
background.html | 2 +-
protos/DeviceMessages.proto | 5 +-
protos/DeviceName.proto | 3 +
protos/Groups.proto | 9 +
protos/SignalService.proto | 3 +
protos/SignalStorage.proto | 3 +
protos/Stickers.proto | 3 +
protos/SubProtocol.proto | 19 +-
protos/UnidentifiedDelivery.proto | 9 +-
stylesheets/_modules.scss | 185 ----------
.../AnnouncementsOnlyGroupBanner.scss | 24 ++
stylesheets/components/CompositionArea.scss | 183 +++++++++
.../components/ConversationHeader.scss | 4 +
stylesheets/manifest.scss | 2 +
test/index.html | 2 +-
ts/background.ts | 3 +-
.../AnnouncementsOnlyGroupBanner.tsx | 67 ++++
ts/components/CompositionArea.stories.tsx | 18 +-
ts/components/CompositionArea.tsx | 80 ++--
ts/components/ForwardMessageModal.stories.tsx | 12 +
ts/components/ForwardMessageModal.tsx | 349 +++++++++---------
.../conversation/ConversationHeader.tsx | 19 +-
.../conversation/GroupV2Change.stories.tsx | 58 +++
.../ConversationDetails.stories.tsx | 12 +
.../ConversationDetails.tsx | 21 +-
.../GroupV2Permissions.stories.tsx | 3 +
.../GroupV2Permissions.tsx | 29 +-
ts/groupChange.ts | 23 ++
ts/groups.ts | 70 ++--
ts/model-types.d.ts | 1 +
ts/models/conversations.ts | 70 ++++
ts/models/messages.ts | 9 +
ts/state/ducks/conversations.ts | 2 +
ts/state/selectors/conversations.ts | 30 ++
ts/state/selectors/message.ts | 5 +
ts/state/smart/CompositionArea.tsx | 12 +-
ts/state/smart/ConversationHeader.tsx | 6 +-
ts/state/smart/GroupV2Permissions.tsx | 1 +
ts/textsecure/WebAPI.ts | 3 +
ts/util/whatTypeOfConversation.ts | 7 +-
ts/views/conversation_view.ts | 70 +++-
ts/window.d.ts | 1 +
43 files changed, 1057 insertions(+), 455 deletions(-)
create mode 100644 stylesheets/components/AnnouncementsOnlyGroupBanner.scss
create mode 100644 stylesheets/components/CompositionArea.scss
create mode 100644 ts/components/AnnouncementsOnlyGroupBanner.tsx
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 6f2723a7d..21ca1380b 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -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"
}
}
diff --git a/background.html b/background.html
index bbbb89ff6..57502a97d 100644
--- a/background.html
+++ b/background.html
@@ -113,7 +113,7 @@
diff --git a/protos/DeviceMessages.proto b/protos/DeviceMessages.proto
index 4368a712e..d926f238a 100644
--- a/protos/DeviceMessages.proto
+++ b/protos/DeviceMessages.proto
@@ -1,3 +1,6 @@
+// Copyright 2014-2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
package signalservice;
message ProvisioningUuid {
@@ -27,4 +30,4 @@ enum ProvisioningVersion {
INITIAL = 0;
TABLET_SUPPORT = 1;
CURRENT = 1;
-}
\ No newline at end of file
+}
diff --git a/protos/DeviceName.proto b/protos/DeviceName.proto
index ec2859b18..512d76505 100644
--- a/protos/DeviceName.proto
+++ b/protos/DeviceName.proto
@@ -1,3 +1,6 @@
+// Copyright 2018-2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
package signalservice;
message DeviceName {
diff --git a/protos/Groups.proto b/protos/Groups.proto
index 6b0e6df8c..f3d04c3a9 100644
--- a/protos/Groups.proto
+++ b/protos/Groups.proto
@@ -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
diff --git a/protos/SignalService.proto b/protos/SignalService.proto
index fb2b4c02b..8cde091b3 100644
--- a/protos/SignalService.proto
+++ b/protos/SignalService.proto
@@ -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;
diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto
index da2ee9310..eb8f9586f 100644
--- a/protos/SignalStorage.proto
+++ b/protos/SignalStorage.proto
@@ -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";
diff --git a/protos/Stickers.proto b/protos/Stickers.proto
index 82dfa0dbf..3c66bd0d6 100644
--- a/protos/Stickers.proto
+++ b/protos/Stickers.proto
@@ -1,3 +1,6 @@
+// Copyright 2019-2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
package signalservice;
message StickerPack {
diff --git a/protos/SubProtocol.proto b/protos/SubProtocol.proto
index c266e53f2..98641ee36 100644
--- a/protos/SubProtocol.proto
+++ b/protos/SubProtocol.proto
@@ -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 .
- */
+// Copyright 2014-2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
package signalservice;
option java_package = "org.whispersystems.websocket.messages.protobuf";
diff --git a/protos/UnidentifiedDelivery.proto b/protos/UnidentifiedDelivery.proto
index d0a6f9fc0..1364eee80 100644
--- a/protos/UnidentifiedDelivery.proto
+++ b/protos/UnidentifiedDelivery.proto
@@ -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";
@@ -34,10 +37,10 @@ message UnidentifiedSenderMessage {
PREKEY_MESSAGE = 1;
MESSAGE = 2;
// Further cases should line up with Envelope.Type, even though old cases don't.
-
+
// Our parser does not handle reserved in enums: DESKTOP-1569
// reserved 3 to 6;
-
+
SENDERKEY_MESSAGE = 7;
PLAINTEXT_CONTENT = 8;
}
@@ -45,7 +48,7 @@ message UnidentifiedSenderMessage {
enum ContentHint {
// Show an error immediately; it was important but we can't retry.
DEFAULT = 0;
-
+
// Sender will try to resend; delay any error UI if possible
RESENDABLE = 1;
diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss
index 911f2053b..57f3793dd 100644
--- a/stylesheets/_modules.scss
+++ b/stylesheets/_modules.scss
@@ -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 {
diff --git a/stylesheets/components/AnnouncementsOnlyGroupBanner.scss b/stylesheets/components/AnnouncementsOnlyGroupBanner.scss
new file mode 100644
index 000000000..8896ead30
--- /dev/null
+++ b/stylesheets/components/AnnouncementsOnlyGroupBanner.scss
@@ -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;
+ }
+ }
+}
diff --git a/stylesheets/components/CompositionArea.scss b/stylesheets/components/CompositionArea.scss
new file mode 100644
index 000000000..93f5cfedb
--- /dev/null
+++ b/stylesheets/components/CompositionArea.scss
@@ -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;
+ }
+ }
+ }
+}
diff --git a/stylesheets/components/ConversationHeader.scss b/stylesheets/components/ConversationHeader.scss
index 459481d41..0b317e300 100644
--- a/stylesheets/components/ConversationHeader.scss
+++ b/stylesheets/components/ConversationHeader.scss
@@ -195,6 +195,10 @@
opacity: 1;
}
+ &--show-disabled {
+ opacity: 0.5;
+ }
+
@include light-theme {
&:hover,
&:focus {
diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss
index 34de63b0c..886a8d012 100644
--- a/stylesheets/manifest.scss
+++ b/stylesheets/manifest.scss
@@ -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';
diff --git a/test/index.html b/test/index.html
index 8cfa6e484..a4e711398 100644
--- a/test/index.html
+++ b/test/index.html
@@ -95,7 +95,7 @@
diff --git a/ts/background.ts b/ts/background.ts
index 229783a6c..590272add 100644
--- a/ts/background.ts
+++ b/ts/background.ts
@@ -1198,7 +1198,7 @@ export async function startApp(): Promise {
'.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 {
// 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(
diff --git a/ts/components/AnnouncementsOnlyGroupBanner.tsx b/ts/components/AnnouncementsOnlyGroupBanner.tsx
new file mode 100644
index 000000000..2f8242c90
--- /dev/null
+++ b/ts/components/AnnouncementsOnlyGroupBanner.tsx
@@ -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;
+ i18n: LocalizerType;
+ openConversation: (conversationId: string) => unknown;
+};
+
+export const AnnouncementsOnlyGroupBanner = ({
+ groupAdmins,
+ i18n,
+ openConversation,
+}: PropsType): JSX.Element => {
+ const [isShowingAdmins, setIsShowingAdmins] = useState(false);
+
+ return (
+ <>
+ {isShowingAdmins && (
+ setIsShowingAdmins(false)}
+ title={i18n('AnnouncementsOnlyGroupBanner--modal')}
+ >
+ {groupAdmins.map(admin => (
+ {
+ 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}
+ />
+ ))}
+
+ )}
+
+ setIsShowingAdmins(true)}
+ >
+ {i18n('AnnouncementsOnlyGroupBanner--admins')}
+ ,
+ ]}
+ />
+
+ >
+ );
+};
diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx
index f90d4e703..27b16e283 100644
--- a/ts/components/CompositionArea.stories.tsx
+++ b/ts/components/CompositionArea.stories.tsx
@@ -91,7 +91,14 @@ const createProps = (overrideProps: Partial = {}): 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 ;
});
+
+story.add('Announcements Only group', () => (
+
+));
diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx
index 3e39b2ce2..50ae78583 100644
--- a/ts/components/CompositionArea.tsx
+++ b/ts/components/CompositionArea.tsx
@@ -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;
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 = (
<>
-
+
{showMediaQualitySelector ? (
-
+
+
@@ -351,7 +361,7 @@ export const CompositionArea = ({
const stickerButtonPlacement = large ? 'top-start' : 'top-end';
const stickerButtonFragment = withStickers ? (
-
+
{isFetchingUUID ? (
@@ -436,10 +446,10 @@ export const CompositionArea = ({
/>
) : (
<>
-
+
{i18n('CompositionArea--sms-only__title')}
-
+
{i18n('CompositionArea--sms-only__body')}
>
@@ -490,16 +500,24 @@ export const CompositionArea = ({
);
}
+ if (announcementsOnly && !areWeAdmin) {
+ return (
+
+ );
+ }
+
return (
-
-
+
+
{quotedMessageProps && (
@@ -539,7 +557,7 @@ export const CompositionArea = ({
)}
{draftAttachments.length ? (
-
+
{!large ? leftHandSideButtonsFragment : null}
-
+
{leftHandSideButtonsFragment}
diff --git a/ts/components/ForwardMessageModal.stories.tsx b/ts/components/ForwardMessageModal.stories.tsx
index 27b633767..f68b0d941 100644
--- a/ts/components/ForwardMessageModal.stories.tsx
+++ b/ts/components/ForwardMessageModal.stories.tsx
@@ -118,3 +118,15 @@ story.add('media attachments', () => {
/>
);
});
+
+story.add('announcement only groups non-admin', () => (
+
+));
diff --git a/ts/components/ForwardMessageModal.tsx b/ts/components/ForwardMessageModal.tsx
index 55ed5e982..5ca7f7ec2 100644
--- a/ts/components/ForwardMessageModal.tsx
+++ b/ts/components/ForwardMessageModal.tsx
@@ -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 = ({
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 = ({
}
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 = ({
}, []);
return (
-
-
-
+ {cannotMessage && (
+
setCannotMessage(false)}
>
+ {i18n('GroupV2--cannot-send')}
+
+ )}
+
+
+
+ {isEditingMessage ? (
+ setIsEditingMessage(false)}
+ type="button"
+ >
+
+
+ ) : (
+
+ )}
+
{i18n('forwardMessage')}
+
{isEditingMessage ? (
-
setIsEditingMessage(false)}
- type="button"
- >
-
-
- ) : (
-
- )}
-
{i18n('forwardMessage')}
-
- {isEditingMessage ? (
-
- {linkPreview ? (
-
-
+ {linkPreview ? (
+
+ removeLinkPreview()}
+ title={linkPreview.title}
+ />
+
+ ) : null}
+ {attachmentsToForward && attachmentsToForward.length ? (
+ removeLinkPreview()}
- title={linkPreview.title}
+ onCloseAttachment={(attachment: AttachmentType) => {
+ const newAttachments = attachmentsToForward.filter(
+ currentAttachment => currentAttachment !== attachment
+ );
+ setAttachmentsToForward(newAttachments);
+ }}
/>
-
- ) : null}
- {attachmentsToForward && attachmentsToForward.length ? (
-
{
- const newAttachments = attachmentsToForward.filter(
- currentAttachment => currentAttachment !== attachment
- );
- setAttachmentsToForward(newAttachments);
- }}
- />
- ) : null}
-
-
{
- setMessageBodyText(messageText);
- onEditorStateChange(messageText, bodyRanges, caretLocation);
- }}
- onPickEmoji={onPickEmoji}
- onSubmit={forwardMessage}
- onTextTooLong={onTextTooLong}
- />
-
-
+ {
+ setMessageBodyText(messageText);
+ onEditorStateChange(messageText, bodyRanges, caretLocation);
+ }}
+ onPickEmoji={onPickEmoji}
+ onSubmit={forwardMessage}
+ onTextTooLong={onTextTooLong}
/>
+
+
+
-
- ) : (
-
-
{
- setSearchTerm(event.target.value);
- }}
- ref={inputRef}
- value={searchTerm}
- />
- {candidateConversations.length ? (
-
- {({ 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 (
-
-
{
- if (
- disabledReason !==
- ContactCheckboxDisabledReason.MaximumContactsSelected
- ) {
- toggleSelectedConversation(conversationId);
- }
- }}
- onSelectConversation={shouldNeverBeCalled}
- renderMessageSearchResult={() => {
- shouldNeverBeCalled();
- return
;
- }}
- rowCount={rowCount}
- shouldRecomputeRowHeights={false}
- showChooseGroupMembers={shouldNeverBeCalled}
- startNewConversationFromPhoneNumber={
- shouldNeverBeCalled
- }
- />
-
- );
- /* eslint-enable jsx-a11y/no-static-element-interactions */
+ ) : (
+
+
{
+ setSearchTerm(event.target.value);
}}
-
- ) : (
-
- {i18n('noContactsFound')}
-
- )}
-
- )}
-
-
- {Boolean(selectedContacts.length) &&
- selectedContacts.map(contact => contact.title).join(', ')}
-
-
- {isEditingMessage || !isMessageEditable ? (
-
- ) : (
-
setIsEditingMessage(true)}
- />
- )}
+ {candidateConversations.length ? (
+
+ {({ 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 (
+
+
{
+ if (
+ disabledReason !==
+ ContactCheckboxDisabledReason.MaximumContactsSelected
+ ) {
+ toggleSelectedConversation(conversationId);
+ }
+ }}
+ onSelectConversation={shouldNeverBeCalled}
+ renderMessageSearchResult={() => {
+ shouldNeverBeCalled();
+ return
;
+ }}
+ rowCount={rowCount}
+ shouldRecomputeRowHeights={false}
+ showChooseGroupMembers={shouldNeverBeCalled}
+ startNewConversationFromPhoneNumber={
+ shouldNeverBeCalled
+ }
+ />
+
+ );
+ /* eslint-enable jsx-a11y/no-static-element-interactions */
+ }}
+
+ ) : (
+
+ {i18n('noContactsFound')}
+
+ )}
+
+ )}
+
+
+ {Boolean(selectedContacts.length) &&
+ selectedContacts.map(contact => contact.title).join(', ')}
+
+
+ {isEditingMessage || !isMessageEditable ? (
+
+ ) : (
+ setIsEditingMessage(true)}
+ />
+ )}
+
-
-
+
+ >
);
};
diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx
index a5441a1ec..d4a6244fe 100644
--- a/ts/components/conversation/ConversationHeader.tsx
+++ b/ts/components/conversation/ConversationHeader.tsx
@@ -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
{
private renderOutgoingCallButtons(): ReactNode {
const {
+ announcementsOnly,
+ areWeAdmin,
i18n,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
@@ -301,15 +305,18 @@ export class ConversationHeader extends React.Component {
const videoButton = (
);
@@ -341,14 +348,14 @@ export class ConversationHeader extends React.Component {
return (
{isNarrow ? null : i18n('joinOngoingCall')}
diff --git a/ts/components/conversation/GroupV2Change.stories.tsx b/ts/components/conversation/GroupV2Change.stories.tsx
index f8e7ef0fb..ce5efb9b0 100644
--- a/ts/components/conversation/GroupV2Change.stories.tsx
+++ b/ts/components/conversation/GroupV2Change.stories.tsx
@@ -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,
+ },
+ ],
+ })}
+ >
+ );
});
diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx
index 369903dd0..8b742891a 100644
--- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx
+++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx
@@ -139,3 +139,15 @@ story.add('Group Links On', () => {
return ;
});
+
+story.add('Group add with missing capabilities', () => (
+ {
+ const error = new Error();
+ error.code = 'E_NO_CAPABILITY';
+ throw error;
+ }}
+ />
+));
diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx
index 4ca850e7d..ca1f60b7c 100644
--- a/ts/components/conversation/conversation-details/ConversationDetails.tsx
+++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx
@@ -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 = ({
addGroupMembersRequestState,
setAddGroupMembersRequestState,
] = useState(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 = ({
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 = ({
return (
+ {membersMissingCapability && (
+
setMembersMissingCapability(false)}
+ >
+ {i18n('GroupV2--add--missing-capability')}
+
+ )}
+
({
@@ -36,6 +38,7 @@ const createProps = (): PropsType => ({
'setAccessControlAttributesSetting'
),
setAccessControlMembersSetting: action('setAccessControlMembersSetting'),
+ setAnnouncementsOnly: action('setAnnouncementsOnly'),
});
story.add('Basic', () => {
diff --git a/ts/components/conversation/conversation-details/GroupV2Permissions.tsx b/ts/components/conversation/conversation-details/GroupV2Permissions.tsx
index 417989b2f..18870c643 100644
--- a/ts/components/conversation/conversation-details/GroupV2Permissions.tsx
+++ b/ts/components/conversation/conversation-details/GroupV2Permissions.tsx
@@ -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 = ({
+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 = ({
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 (
@@ -60,6 +72,19 @@ export const GroupV2Permissions: React.ComponentType = ({
/>
}
/>
+ {conversation.areWeAdmin && conversation.announcementsOnlyReady && (
+
+ }
+ />
+ )}
);
};
diff --git a/ts/groupChange.ts b/ts/groupChange.ts
index ee7a86726..9f4f20fee 100644
--- a/ts/groupChange.ts
+++ b/ts/groupChange.ts
@@ -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);
}
diff --git a/ts/groups.ts b/ts/groups.ts
index 89a540c9f..8a91130ff 100644
--- a/ts/groups.ts
+++ b/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;
diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts
index ceb6f0b65..e85630b27 100644
--- a/ts/model-types.d.ts
+++ b/ts/model-types.d.ts
@@ -290,6 +290,7 @@ export type ConversationAttributesType = {
members: AccessRequiredEnum;
addFromInviteLink: AccessRequiredEnum;
};
+ announcementsOnly?: boolean;
avatar?: {
url: string;
path: string;
diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts
index d3d42991d..e0d80c534 100644
--- a/ts/models/conversations.ts
+++ b/ts/models/conversations.ts
@@ -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): Promise {
+ 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 {
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 {
+ 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 || {};
diff --git a/ts/models/messages.ts b/ts/models/messages.ts
index 33cd00173..05757518c 100644
--- a/ts/models/messages.ts
+++ b/ts/models/messages.ts
@@ -2737,6 +2737,15 @@ export class MessageModel extends window.Backbone.Model {
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
diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts
index 3cde62c9e..16f780d85 100644
--- a/ts/state/ducks/conversations.ts
+++ b/ts/state/ducks/conversations.ts
@@ -121,6 +121,8 @@ export type ConversationType = {
accessControlAddFromInviteLink?: number;
accessControlAttributes?: number;
accessControlMembers?: number;
+ announcementsOnly?: boolean;
+ announcementsOnlyReady?: boolean;
expireTimer?: number;
memberships?: Array<{
conversationId: string;
diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts
index 649193a9f..54a6cd2d2 100644
--- a/ts/state/selectors/conversations.ts
+++ b/ts/state/selectors/conversations.ts
@@ -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 => {
+ const { groupId, groupVersion, memberships = [] } = conversationSelector(
+ conversationId
+ );
+
+ if (
+ !isGroupV2({
+ groupId,
+ groupVersion,
+ })
+ ) {
+ return [];
+ }
+
+ const admins: Array = [];
+ memberships.forEach(membership => {
+ if (membership.isAdmin) {
+ const admin = conversationSelector(membership.conversationId);
+ admins.push(admin);
+ }
+ });
+ return admins;
+ };
+ }
+);
diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts
index 83fbdac45..0794e3416 100644
--- a/ts/state/selectors/message.ts
+++ b/ts/state/selectors/message.ts
@@ -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 (
diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx
index 6bc555603..88b843227 100644
--- a/ts/state/smart/CompositionArea.tsx
+++ b/ts/state/smart/CompositionArea.tsx
@@ -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),
};
};
diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx
index 29d30b979..cba5be402 100644
--- a/ts/state/smart/ConversationHeader.tsx
+++ b/ts/state/smart/ConversationHeader.tsx
@@ -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,
diff --git a/ts/state/smart/GroupV2Permissions.tsx b/ts/state/smart/GroupV2Permissions.tsx
index 80ac406e0..64cf0d0b6 100644
--- a/ts/state/smart/GroupV2Permissions.tsx
+++ b/ts/state/smart/GroupV2Permissions.tsx
@@ -15,6 +15,7 @@ export type SmartGroupV2PermissionsProps = {
conversationId: string;
setAccessControlAttributesSetting: (value: number) => void;
setAccessControlMembersSetting: (value: number) => void;
+ setAnnouncementsOnly: (value: boolean) => void;
};
const mapStateToProps = (
diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts
index b93a98a63..ca5c51ce0 100644
--- a/ts/textsecure/WebAPI.ts
+++ b/ts/textsecure/WebAPI.ts
@@ -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,
diff --git a/ts/util/whatTypeOfConversation.ts b/ts/util/whatTypeOfConversation.ts
index ac03274b4..db818837c 100644
--- a/ts/util/whatTypeOfConversation.ts
+++ b/ts/util/whatTypeOfConversation.ts
@@ -25,7 +25,7 @@ export function isMe(conversationAttrs: ConversationAttributesType): boolean {
}
export function isGroupV1(
- conversationAttrs: ConversationAttributesType
+ conversationAttrs: Pick
): 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) {
diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts
index 7a2b5189b..15b6407cd 100644
--- a/ts/views/conversation_view.ts
+++ b/ts/views/conversation_view.ts
@@ -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({
@@ -2316,17 +2327,24 @@ Whisper.ConversationView = Whisper.View.extend({
includedAttachments?: Array,
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 = [];
@@ -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 {
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 {
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 {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
@@ -3620,6 +3653,15 @@ Whisper.ConversationView = Whisper.View.extend({
});
},
+ async setAnnouncementsOnly(value: boolean): Promise {
+ const { model }: { model: ConversationModel } = this;
+
+ await this.longRunningTaskWrapper({
+ name: 'updateAnnouncementsOnly',
+ task: async () => model.updateAnnouncementsOnly(value),
+ });
+ },
+
async destroyMessages() {
const { model }: { model: ConversationModel } = this;
diff --git a/ts/window.d.ts b/ts/window.d.ts
index 9bb12201b..5128e997e 100644
--- a/ts/window.d.ts
+++ b/ts/window.d.ts
@@ -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;