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 ? ( -
+
+
+ ) : ( +
{isEditingMessage ? ( - - ) : ( -
- {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 ? ( -
-
- + + ); }; 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 = ( 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;