Support for announcement-only groups

This commit is contained in:
Josh Perez 2021-07-20 16:18:35 -04:00 committed by GitHub
parent 863ae9ed83
commit 56d5d283bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1057 additions and 455 deletions

View File

@ -3502,6 +3502,18 @@
"description": "Shown if you click on a sgnl:// link not currently supported by Desktop" "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": { "GroupV2--join--invalid-link--title": {
"message": "Invalid Link", "message": "Invalid Link",
"description": "Shown if we are unable to parse a group 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" "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": { "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$", "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).", "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.", "message": "Choose who can add members to this group.",
"description": "This is the additional info for the 'who can add members' panel" "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": { "ConversationDetails--requests-and-invites": {
"message": "Requests & Invites", "message": "Requests & Invites",
"description": "This is a button to display which members have been invited but have not joined yet" "description": "This is a button to display which members have been invited but have not joined yet"
@ -5740,5 +5797,23 @@
"ProfileEditorModal--error": { "ProfileEditorModal--error": {
"message": "Your profile could not be updated. Please try again.", "message": "Your profile could not be updated. Please try again.",
"description": "Error message when something goes wrong updating your profile." "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"
} }
} }

View File

@ -113,7 +113,7 @@
<div class='compose'> <div class='compose'>
<form class='send clearfix file-input'> <form class='send clearfix file-input'>
<input type="file" class="file-input" multiple="multiple"> <input type="file" class="file-input" multiple="multiple">
<div class='composition-area-placeholder'></div> <div class='CompositionArea__placeholder'></div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -1,3 +1,6 @@
// Copyright 2014-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
package signalservice; package signalservice;
message ProvisioningUuid { message ProvisioningUuid {

View File

@ -1,3 +1,6 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
package signalservice; package signalservice;
message DeviceName { message DeviceName {

View File

@ -1,5 +1,8 @@
syntax = "proto3"; syntax = "proto3";
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
package signalservice; package signalservice;
option java_package = "org.whispersystems.signalservice.protos.groups"; option java_package = "org.whispersystems.signalservice.protos.groups";
@ -68,6 +71,7 @@ message Group {
repeated MemberPendingAdminApproval membersPendingAdminApproval = 9; repeated MemberPendingAdminApproval membersPendingAdminApproval = 9;
bytes inviteLinkPassword = 10; bytes inviteLinkPassword = 10;
bytes descriptionBytes = 11; bytes descriptionBytes = 11;
bool announcementsOnly = 12;
} }
message GroupChange { message GroupChange {
@ -153,6 +157,10 @@ message GroupChange {
bytes descriptionBytes = 1; bytes descriptionBytes = 1;
} }
message ModifyAnnouncementsOnlyAction {
bool announcementsOnly = 1;
}
bytes sourceUuid = 1; // Who made the change bytes sourceUuid = 1; // Who made the change
uint32 version = 2; // The change version number uint32 version = 2; // The change version number
@ -174,6 +182,7 @@ message GroupChange {
repeated PromoteMemberPendingAdminApprovalAction promoteMemberPendingAdminApprovals = 18; // change epoch = 1 repeated PromoteMemberPendingAdminApprovalAction promoteMemberPendingAdminApprovals = 18; // change epoch = 1
ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1 ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1
ModifyDescriptionAction modifyDescription = 20; // change epoch = 2 ModifyDescriptionAction modifyDescription = 20; // change epoch = 2
ModifyAnnouncementsOnlyAction modifyAnnouncementsOnly = 21; // change epoch = 3
} }
bytes actions = 1; // The serialized actions bytes actions = 1; // The serialized actions

View File

@ -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 // Source: https://github.com/signalapp/libsignal-service-java/blob/4684a49b2ed8f32be619e0d0eea423626b6cb2cb/protobuf/SignalService.proto
package signalservice; package signalservice;

View File

@ -1,3 +1,6 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
package signalservice; package signalservice;
option java_package = "org.whispersystems.signalservice.internal.storage"; option java_package = "org.whispersystems.signalservice.internal.storage";

View File

@ -1,3 +1,6 @@
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
package signalservice; package signalservice;
message StickerPack { message StickerPack {

View File

@ -1,19 +1,6 @@
/** // Copyright 2014-2021 Signal Messenger, LLC
* Copyright (C) 2014 Open WhisperSystems // SPDX-License-Identifier: AGPL-3.0-only
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package signalservice; package signalservice;
option java_package = "org.whispersystems.websocket.messages.protobuf"; option java_package = "org.whispersystems.websocket.messages.protobuf";

View File

@ -1,3 +1,6 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
package signalservice; package signalservice;
option java_package = "org.whispersystems.libsignal.protocol"; option java_package = "org.whispersystems.libsignal.protocol";

View File

@ -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
.module-last-seen-indicator { .module-last-seen-indicator {

View File

@ -0,0 +1,24 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.AnnouncementsOnlyGroupBanner {
&__banner {
@include font-subtitle;
padding: 16px;
text-align: center;
@include light-theme {
border-top: 1px solid $color-gray-05;
color: $color-gray-60;
}
@include dark-theme {
border-top: 1px solid $color-gray-05;
color: $color-gray-05;
}
&--admins {
@include button-reset;
color: $color-ultramarine;
}
}
}

View File

@ -0,0 +1,183 @@
.CompositionArea {
position: relative;
min-height: 42px;
padding-top: 6px;
&__placeholder {
flex-grow: 1;
margin: {
bottom: 6px;
}
}
&__row {
display: flex;
flex-direction: row;
&--center {
justify-content: center;
}
&--padded {
padding: 0 12px;
}
&--control-row {
margin-top: 8px;
}
&--column {
flex-direction: column;
}
}
&__button-cell {
margin-top: 2px;
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 100%;
flex-shrink: 0;
&--mic-active {
width: 150px;
}
&--large-right {
margin-left: auto;
margin-right: 4px;
}
&--large-right-mic-active {
margin-left: auto;
margin-right: 12px;
}
}
&__send-button {
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
background: none;
border: none;
&::after {
display: block;
content: '';
width: 24px;
height: 24px;
flex-shrink: 0;
@include color-svg('../images/icons/v2/send-24.svg', $color-ultramarine);
}
}
&__input {
flex-grow: 1;
}
$comp-area: &;
&__toggle-large {
width: 48px;
height: 24px;
position: absolute;
left: calc(50% - 24px);
top: -18px;
border-radius: 12px 12px 0 0;
pointer-events: none;
opacity: 0;
transition: opacity 200ms ease-out;
#{$comp-area}:hover & {
opacity: 1;
pointer-events: all;
}
@include light-theme() {
background-color: $color-white;
}
@include dark-theme() {
background-color: $color-gray-95;
}
&__button {
width: 48px;
height: 24px;
border: none;
@include light-theme() {
@include color-svg(
'../images/icons/v2/expand-up-20.svg',
$color-gray-45,
false
);
}
@include dark-theme() {
@include color-svg(
'../images/icons/v2/expand-up-20.svg',
$color-gray-45,
false
);
}
&--large-active {
@include light-theme() {
@include color-svg(
'../images/icons/v2/collapse-down-20.svg',
$color-gray-45,
false
);
}
@include dark-theme() {
@include color-svg(
'../images/icons/v2/collapse-down-20.svg',
$color-gray-45,
false
);
}
}
}
}
&__attachment-list {
width: 100%;
}
&--sms-only {
display: flex;
flex-direction: column;
align-items: center;
// Note the margin in &__placeholder above
padding: 14px 16px 18px 16px;
&:not(.module-composition-area--pending) {
@include light-theme {
border-top: 1px solid $color-gray-05;
}
@include dark-theme {
border-top: 1px solid $color-gray-75;
}
}
&__title {
@include font-body-2-bold;
margin: 0 0 2px 0;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-05;
}
}
&__body {
@include font-body-2;
text-align: center;
margin: 0;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-05;
}
}
}
}

View File

@ -195,6 +195,10 @@
opacity: 1; opacity: 1;
} }
&--show-disabled {
opacity: 0.5;
}
@include light-theme { @include light-theme {
&:hover, &:hover,
&:focus { &:focus {

View File

@ -29,12 +29,14 @@
// New style: components // New style: components
@import './components/AddGroupMembersModal.scss'; @import './components/AddGroupMembersModal.scss';
@import './components/App.scss'; @import './components/App.scss';
@import './components/AnnouncementsOnlyGroupBanner.scss';
@import './components/Avatar.scss'; @import './components/Avatar.scss';
@import './components/AvatarInput.scss'; @import './components/AvatarInput.scss';
@import './components/Button.scss'; @import './components/Button.scss';
@import './components/CallingScreenSharingController.scss'; @import './components/CallingScreenSharingController.scss';
@import './components/CallingSelectPresentingSourcesModal.scss'; @import './components/CallingSelectPresentingSourcesModal.scss';
@import './components/ChatColorPicker.scss'; @import './components/ChatColorPicker.scss';
@import './components/CompositionArea.scss';
@import './components/ContactName.scss'; @import './components/ContactName.scss';
@import './components/ContactPill.scss'; @import './components/ContactPill.scss';
@import './components/ContactPills.scss'; @import './components/ContactPills.scss';

View File

@ -95,7 +95,7 @@
<div class='compose'> <div class='compose'>
<form class='send clearfix file-input'> <form class='send clearfix file-input'>
<input type="file" class="file-input" multiple="multiple"> <input type="file" class="file-input" multiple="multiple">
<div class='composition-area-placeholder'></div> <div class='CompositionArea__placeholder'></div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -1198,7 +1198,7 @@ export async function startApp(): Promise<void> {
'.module-conversation-list__item--contact-or-conversation' '.module-conversation-list__item--contact-or-conversation'
), ),
document.querySelector('.module-search-results'), document.querySelector('.module-search-results'),
document.querySelector('.module-composition-area .ql-editor'), document.querySelector('.CompositionArea .ql-editor'),
]; ];
const focusedIndex = targets.findIndex(target => { const focusedIndex = targets.findIndex(target => {
if (!target || !focusedElement) { if (!target || !focusedElement) {
@ -2318,6 +2318,7 @@ export async function startApp(): Promise<void> {
// Note: we always have to register our capabilities all at once, so we do this // Note: we always have to register our capabilities all at once, so we do this
// after connect on every startup // after connect on every startup
await server.registerCapabilities({ await server.registerCapabilities({
announcementGroup: true,
'gv2-3': true, 'gv2-3': true,
'gv1-migration': true, 'gv1-migration': true,
senderKey: window.Signal.RemoteConfig.isEnabled( senderKey: window.Signal.RemoteConfig.isEnabled(

View File

@ -0,0 +1,67 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import { ConversationType } from '../state/ducks/conversations';
import { Intl } from './Intl';
import { LocalizerType } from '../types/Util';
import { Modal } from './Modal';
import { ConversationListItem } from './conversationList/ConversationListItem';
type PropsType = {
groupAdmins: Array<ConversationType>;
i18n: LocalizerType;
openConversation: (conversationId: string) => unknown;
};
export const AnnouncementsOnlyGroupBanner = ({
groupAdmins,
i18n,
openConversation,
}: PropsType): JSX.Element => {
const [isShowingAdmins, setIsShowingAdmins] = useState(false);
return (
<>
{isShowingAdmins && (
<Modal
i18n={i18n}
onClose={() => setIsShowingAdmins(false)}
title={i18n('AnnouncementsOnlyGroupBanner--modal')}
>
{groupAdmins.map(admin => (
<ConversationListItem
{...admin}
i18n={i18n}
onClick={() => {
openConversation(admin.id);
}}
// Required by the component but unecessary for us
style={{}}
// We don't want these values to show
draftPreview=""
lastMessage={undefined}
lastUpdated={undefined}
typingContact={undefined}
/>
))}
</Modal>
)}
<div className="AnnouncementsOnlyGroupBanner__banner">
<Intl
i18n={i18n}
id="AnnouncementsOnlyGroupBanner--announcements-only"
components={[
<button
className="AnnouncementsOnlyGroupBanner__banner--admins"
type="button"
onClick={() => setIsShowingAdmins(true)}
>
{i18n('AnnouncementsOnlyGroupBanner--admins')}
</button>,
]}
/>
</div>
</>
);
};

View File

@ -91,7 +91,14 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
title: '', title: '',
// GroupV1 Disabled Actions // GroupV1 Disabled Actions
onStartGroupMigration: action('onStartGroupMigration'), 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'), onCancelJoinRequest: action('onCancelJoinRequest'),
// SMS-only // SMS-only
isSMSOnly: overrideProps.isSMSOnly || false, isSMSOnly: overrideProps.isSMSOnly || false,
@ -157,3 +164,12 @@ story.add('Attachments', () => {
return <CompositionArea {...props} />; return <CompositionArea {...props} />;
}); });
story.add('Announcements Only group', () => (
<CompositionArea
{...createProps({
announcementsOnly: true,
areWeAdmin: false,
})}
/>
));

View File

@ -37,11 +37,16 @@ import { MediaQualitySelector } from './MediaQualitySelector';
import { Quote, Props as QuoteProps } from './conversation/Quote'; import { Quote, Props as QuoteProps } from './conversation/Quote';
import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { LinkPreviewWithDomain } from '../types/LinkPreview'; import { LinkPreviewWithDomain } from '../types/LinkPreview';
import { ConversationType } from '../state/ducks/conversations';
import { AnnouncementsOnlyGroupBanner } from './AnnouncementsOnlyGroupBanner';
export type OwnProps = { export type OwnProps = {
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
readonly areWePending?: boolean; readonly areWePending?: boolean;
readonly areWePendingApproval?: boolean; readonly areWePendingApproval?: boolean;
readonly announcementsOnly?: boolean;
readonly areWeAdmin?: boolean;
readonly groupAdmins: Array<ConversationType>;
readonly groupVersion?: 1 | 2; readonly groupVersion?: 1 | 2;
readonly isGroupV1AndDisabled?: boolean; readonly isGroupV1AndDisabled?: boolean;
readonly isMissingMandatoryProfileSharing?: boolean; readonly isMissingMandatoryProfileSharing?: boolean;
@ -74,6 +79,7 @@ export type OwnProps = {
linkPreviewLoading: boolean; linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewWithDomain; linkPreviewResult?: LinkPreviewWithDomain;
onCloseLinkPreview(): unknown; onCloseLinkPreview(): unknown;
openConversation(conversationId: string): unknown;
}; };
export type Props = Pick< export type Props = Pick<
@ -188,8 +194,12 @@ export const CompositionArea = ({
// GroupV1 Disabled Actions // GroupV1 Disabled Actions
isGroupV1AndDisabled, isGroupV1AndDisabled,
onStartGroupMigration, onStartGroupMigration,
// GroupV2 Pending Approval Actions // GroupV2
announcementsOnly,
areWeAdmin,
groupAdmins,
onCancelJoinRequest, onCancelJoinRequest,
openConversation,
// SMS-only contacts // SMS-only contacts
isSMSOnly, isSMSOnly,
isFetchingUUID, isFetchingUUID,
@ -283,7 +293,7 @@ export const CompositionArea = ({
const leftHandSideButtonsFragment = ( const leftHandSideButtonsFragment = (
<> <>
<div className="module-composition-area__button-cell"> <div className="CompositionArea__button-cell">
<EmojiButton <EmojiButton
i18n={i18n} i18n={i18n}
doSend={handleForceSend} doSend={handleForceSend}
@ -295,7 +305,7 @@ export const CompositionArea = ({
/> />
</div> </div>
{showMediaQualitySelector ? ( {showMediaQualitySelector ? (
<div className="module-composition-area__button-cell"> <div className="CompositionArea__button-cell">
<MediaQualitySelector <MediaQualitySelector
i18n={i18n} i18n={i18n}
isHighQuality={shouldSendHighQualityAttachments} isHighQuality={shouldSendHighQualityAttachments}
@ -309,11 +319,11 @@ export const CompositionArea = ({
const micButtonFragment = showMic ? ( const micButtonFragment = showMic ? (
<div <div
className={classNames( className={classNames(
'module-composition-area__button-cell', 'CompositionArea__button-cell',
micActive ? 'module-composition-area__button-cell--mic-active' : null, micActive ? 'CompositionArea__button-cell--mic-active' : null,
large ? 'module-composition-area__button-cell--large-right' : null, large ? 'CompositionArea__button-cell--large-right' : null,
micActive && large micActive && large
? 'module-composition-area__button-cell--large-right-mic-active' ? 'CompositionArea__button-cell--large-right-mic-active'
: null : null
)} )}
ref={micCellRef} ref={micCellRef}
@ -321,7 +331,7 @@ export const CompositionArea = ({
) : null; ) : null;
const attButton = ( const attButton = (
<div className="module-composition-area__button-cell"> <div className="CompositionArea__button-cell">
<div className="choose-file"> <div className="choose-file">
<button <button
type="button" type="button"
@ -336,13 +346,13 @@ export const CompositionArea = ({
const sendButtonFragment = ( const sendButtonFragment = (
<div <div
className={classNames( className={classNames(
'module-composition-area__button-cell', 'CompositionArea__button-cell',
large ? 'module-composition-area__button-cell--large-right' : null large ? 'CompositionArea__button-cell--large-right' : null
)} )}
> >
<button <button
type="button" type="button"
className="module-composition-area__send-button" className="CompositionArea__send-button"
onClick={handleForceSend} onClick={handleForceSend}
aria-label={i18n('sendMessageToContact')} aria-label={i18n('sendMessageToContact')}
/> />
@ -351,7 +361,7 @@ export const CompositionArea = ({
const stickerButtonPlacement = large ? 'top-start' : 'top-end'; const stickerButtonPlacement = large ? 'top-start' : 'top-end';
const stickerButtonFragment = withStickers ? ( const stickerButtonFragment = withStickers ? (
<div className="module-composition-area__button-cell"> <div className="CompositionArea__button-cell">
<StickerButton <StickerButton
i18n={i18n} i18n={i18n}
knownPacks={knownPacks} knownPacks={knownPacks}
@ -422,9 +432,9 @@ export const CompositionArea = ({
return ( return (
<div <div
className={classNames([ className={classNames([
'module-composition-area', 'CompositionArea',
'module-composition-area--sms-only', 'CompositionArea--sms-only',
isFetchingUUID ? 'module-composition-area--pending' : null, isFetchingUUID ? 'CompositionArea--pending' : null,
])} ])}
> >
{isFetchingUUID ? ( {isFetchingUUID ? (
@ -436,10 +446,10 @@ export const CompositionArea = ({
/> />
) : ( ) : (
<> <>
<h2 className="module-composition-area--sms-only__title"> <h2 className="CompositionArea--sms-only__title">
{i18n('CompositionArea--sms-only__title')} {i18n('CompositionArea--sms-only__title')}
</h2> </h2>
<p className="module-composition-area--sms-only__body"> <p className="CompositionArea--sms-only__body">
{i18n('CompositionArea--sms-only__body')} {i18n('CompositionArea--sms-only__body')}
</p> </p>
</> </>
@ -490,16 +500,24 @@ export const CompositionArea = ({
); );
} }
if (announcementsOnly && !areWeAdmin) {
return ( return (
<div className="module-composition-area"> <AnnouncementsOnlyGroupBanner
<div className="module-composition-area__toggle-large"> groupAdmins={groupAdmins}
i18n={i18n}
openConversation={openConversation}
/>
);
}
return (
<div className="CompositionArea">
<div className="CompositionArea__toggle-large">
<button <button
type="button" type="button"
className={classNames( className={classNames(
'module-composition-area__toggle-large__button', 'CompositionArea__toggle-large__button',
large large ? 'CompositionArea__toggle-large__button--large-active' : null
? 'module-composition-area__toggle-large__button--large-active'
: null
)} )}
// This prevents the user from tabbing here // This prevents the user from tabbing here
tabIndex={-1} tabIndex={-1}
@ -509,8 +527,8 @@ export const CompositionArea = ({
</div> </div>
<div <div
className={classNames( className={classNames(
'module-composition-area__row', 'CompositionArea__row',
'module-composition-area__row--column' 'CompositionArea__row--column'
)} )}
> >
{quotedMessageProps && ( {quotedMessageProps && (
@ -539,7 +557,7 @@ export const CompositionArea = ({
</div> </div>
)} )}
{draftAttachments.length ? ( {draftAttachments.length ? (
<div className="module-composition-area__attachment-list"> <div className="CompositionArea__attachment-list">
<AttachmentList <AttachmentList
attachments={draftAttachments} attachments={draftAttachments}
i18n={i18n} i18n={i18n}
@ -553,12 +571,12 @@ export const CompositionArea = ({
</div> </div>
<div <div
className={classNames( className={classNames(
'module-composition-area__row', 'CompositionArea__row',
large ? 'module-composition-area__row--padded' : null large ? 'CompositionArea__row--padded' : null
)} )}
> >
{!large ? leftHandSideButtonsFragment : null} {!large ? leftHandSideButtonsFragment : null}
<div className="module-composition-area__input"> <div className="CompositionArea__input">
<CompositionInput <CompositionInput
i18n={i18n} i18n={i18n}
disabled={disabled} disabled={disabled}
@ -588,8 +606,8 @@ export const CompositionArea = ({
{large ? ( {large ? (
<div <div
className={classNames( className={classNames(
'module-composition-area__row', 'CompositionArea__row',
'module-composition-area__row--control-row' 'CompositionArea__row--control-row'
)} )}
> >
{leftHandSideButtonsFragment} {leftHandSideButtonsFragment}

View File

@ -118,3 +118,15 @@ story.add('media attachments', () => {
/> />
); );
}); });
story.add('announcement only groups non-admin', () => (
<ForwardMessageModal
{...createProps()}
candidateConversations={[
getDefaultConversation({
announcementsOnly: true,
areWeAdmin: false,
}),
]}
/>
));

View File

@ -17,6 +17,7 @@ import { AttachmentList } from './conversation/AttachmentList';
import { AttachmentType } from '../types/Attachment'; import { AttachmentType } from '../types/Attachment';
import { Button } from './Button'; import { Button } from './Button';
import { CompositionInput, InputApi } from './CompositionInput'; import { CompositionInput, InputApi } from './CompositionInput';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import { ConversationList, Row, RowType } from './ConversationList'; import { ConversationList, Row, RowType } from './ConversationList';
import { ConversationType } from '../state/ducks/conversations'; import { ConversationType } from '../state/ducks/conversations';
@ -92,6 +93,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
const [attachmentsToForward, setAttachmentsToForward] = useState(attachments); const [attachmentsToForward, setAttachmentsToForward] = useState(attachments);
const [isEditingMessage, setIsEditingMessage] = useState(false); const [isEditingMessage, setIsEditingMessage] = useState(false);
const [messageBodyText, setMessageBodyText] = useState(messageBody || ''); const [messageBodyText, setMessageBodyText] = useState(messageBody || '');
const [cannotMessage, setCannotMessage] = useState(false);
const isMessageEditable = !isSticker; const isMessageEditable = !isSticker;
@ -186,8 +188,12 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
} }
const selectedContact = contactLookup.get(conversationId); const selectedContact = contactLookup.get(conversationId);
if (selectedContact) { if (selectedContact) {
if (selectedContact.announcementsOnly && !selectedContact.areWeAdmin) {
setCannotMessage(true);
} else {
setSelectedContacts([...nextSelectedContacts, selectedContact]); setSelectedContacts([...nextSelectedContacts, selectedContact]);
} }
}
}, },
[contactLookup, selectedContacts, setSelectedContacts] [contactLookup, selectedContacts, setSelectedContacts]
); );
@ -233,6 +239,16 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
}, []); }, []);
return ( return (
<>
{cannotMessage && (
<ConfirmationDialog
cancelText={i18n('Confirmation--confirm')}
i18n={i18n}
onClose={() => setCannotMessage(false)}
>
{i18n('GroupV2--cannot-send')}
</ConfirmationDialog>
)}
<ModalHost onEscape={handleBackOrClose} onClose={onClose}> <ModalHost onEscape={handleBackOrClose} onClose={onClose}>
<div className="module-ForwardMessageModal"> <div className="module-ForwardMessageModal">
<div <div
@ -333,8 +349,8 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
{candidateConversations.length ? ( {candidateConversations.length ? (
<Measure bounds> <Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => { {({ contentRect, measureRef }: MeasuredComponentProps) => {
// We disable this ESLint rule because we're capturing a bubbled keydown // We disable this ESLint rule because we're capturing a bubbled
// event. See [this note in the jsx-a11y docs][0]. // 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 // [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 */ /* eslint-disable jsx-a11y/no-static-element-interactions */
@ -410,6 +426,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
</div> </div>
</div> </div>
</ModalHost> </ModalHost>
</>
); );
}; };

View File

@ -41,6 +41,8 @@ export type PropsDataType = {
} & Pick< } & Pick<
ConversationType, ConversationType,
| 'acceptedMessageRequest' | 'acceptedMessageRequest'
| 'announcementsOnly'
| 'areWeAdmin'
| 'avatarPath' | 'avatarPath'
| 'canChangeTimer' | 'canChangeTimer'
| 'color' | 'color'
@ -291,6 +293,8 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
private renderOutgoingCallButtons(): ReactNode { private renderOutgoingCallButtons(): ReactNode {
const { const {
announcementsOnly,
areWeAdmin,
i18n, i18n,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
@ -301,15 +305,18 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
const videoButton = ( const videoButton = (
<button <button
type="button" aria-label={i18n('makeOutgoingVideoCall')}
onClick={onOutgoingVideoCallInConversation}
className={classNames( className={classNames(
'module-ConversationHeader__button', 'module-ConversationHeader__button',
'module-ConversationHeader__button--video', 'module-ConversationHeader__button--video',
showBackButton ? null : 'module-ConversationHeader__button--show' showBackButton ? null : 'module-ConversationHeader__button--show',
!showBackButton && announcementsOnly && !areWeAdmin
? 'module-ConversationHeader__button--show-disabled'
: undefined
)} )}
disabled={showBackButton} disabled={showBackButton}
aria-label={i18n('makeOutgoingVideoCall')} onClick={onOutgoingVideoCallInConversation}
type="button"
/> />
); );
@ -341,14 +348,14 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
return ( return (
<button <button
aria-label={i18n('joinOngoingCall')} aria-label={i18n('joinOngoingCall')}
type="button"
onClick={onOutgoingVideoCallInConversation}
className={classNames( className={classNames(
'module-ConversationHeader__button', 'module-ConversationHeader__button',
'module-ConversationHeader__button--join-call', 'module-ConversationHeader__button--join-call',
showBackButton ? null : 'module-ConversationHeader__button--show' showBackButton ? null : 'module-ConversationHeader__button--show'
)} )}
disabled={showBackButton} disabled={showBackButton}
onClick={onOutgoingVideoCallInConversation}
type="button"
> >
{isNarrow ? null : i18n('joinOngoingCall')} {isNarrow ? null : i18n('joinOngoingCall')}
</button> </button>

View File

@ -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,
},
],
})}
</>
);
}); });

View File

@ -139,3 +139,15 @@ story.add('Group Links On', () => {
return <ConversationDetails {...props} isAdmin />; return <ConversationDetails {...props} isAdmin />;
}); });
story.add('Group add with missing capabilities', () => (
<ConversationDetails
{...createProps()}
canEditGroupInfo
addMembers={async () => {
const error = new Error();
error.code = 'E_NO_CAPABILITY';
throw error;
}}
/>
));

View File

@ -30,6 +30,7 @@ import {
import { EditConversationAttributesModal } from './EditConversationAttributesModal'; import { EditConversationAttributesModal } from './EditConversationAttributesModal';
import { RequestState } from './util'; import { RequestState } from './util';
import { getCustomColorStyle } from '../../../util/getCustomColorStyle'; import { getCustomColorStyle } from '../../../util/getCustomColorStyle';
import { ConfirmationDialog } from '../../ConfirmationDialog';
enum ModalState { enum ModalState {
NothingOpen, NothingOpen,
@ -109,6 +110,9 @@ export const ConversationDetails: React.ComponentType<Props> = ({
addGroupMembersRequestState, addGroupMembersRequestState,
setAddGroupMembersRequestState, setAddGroupMembersRequestState,
] = useState<RequestState>(RequestState.Inactive); ] = useState<RequestState>(RequestState.Inactive);
const [membersMissingCapability, setMembersMissingCapability] = useState(
false
);
if (conversation === undefined) { if (conversation === undefined) {
throw new Error('ConversationDetails rendered without a conversation'); throw new Error('ConversationDetails rendered without a conversation');
@ -194,7 +198,12 @@ export const ConversationDetails: React.ComponentType<Props> = ({
setModalState(ModalState.NothingOpen); setModalState(ModalState.NothingOpen);
setAddGroupMembersRequestState(RequestState.Inactive); setAddGroupMembersRequestState(RequestState.Inactive);
} catch (err) { } catch (err) {
if (err.code === 'E_NO_CAPABILITY') {
setMembersMissingCapability(true);
setAddGroupMembersRequestState(RequestState.InactiveWithError); setAddGroupMembersRequestState(RequestState.InactiveWithError);
} else {
setAddGroupMembersRequestState(RequestState.InactiveWithError);
}
} }
}} }}
onClose={() => { onClose={() => {
@ -211,6 +220,16 @@ export const ConversationDetails: React.ComponentType<Props> = ({
return ( return (
<div className="conversation-details-panel"> <div className="conversation-details-panel">
{membersMissingCapability && (
<ConfirmationDialog
cancelText={i18n('Confirmation--confirm')}
i18n={i18n}
onClose={() => setMembersMissingCapability(false)}
>
{i18n('GroupV2--add--missing-capability')}
</ConfirmationDialog>
)}
<ConversationDetailsHeader <ConversationDetailsHeader
canEdit={canEditGroupInfo} canEdit={canEditGroupInfo}
conversation={conversation} conversation={conversation}

View File

@ -27,6 +27,8 @@ const conversation: ConversationType = getDefaultConversation({
title: 'Some Conversation', title: 'Some Conversation',
type: 'group', type: 'group',
sharedGroupNames: [], sharedGroupNames: [],
announcementsOnlyReady: true,
areWeAdmin: true,
}); });
const createProps = (): PropsType => ({ const createProps = (): PropsType => ({
@ -36,6 +38,7 @@ const createProps = (): PropsType => ({
'setAccessControlAttributesSetting' 'setAccessControlAttributesSetting'
), ),
setAccessControlMembersSetting: action('setAccessControlMembersSetting'), setAccessControlMembersSetting: action('setAccessControlMembersSetting'),
setAnnouncementsOnly: action('setAnnouncementsOnly'),
}); });
story.add('Basic', () => { story.add('Basic', () => {

View File

@ -6,6 +6,7 @@ import React from 'react';
import { ConversationType } from '../../../state/ducks/conversations'; import { ConversationType } from '../../../state/ducks/conversations';
import { LocalizerType } from '../../../types/Util'; import { LocalizerType } from '../../../types/Util';
import { getAccessControlOptions } from '../../../util/getAccessControlOptions'; import { getAccessControlOptions } from '../../../util/getAccessControlOptions';
import { SignalService as Proto } from '../../../protobuf';
import { PanelRow } from './PanelRow'; import { PanelRow } from './PanelRow';
import { PanelSection } from './PanelSection'; import { PanelSection } from './PanelSection';
@ -16,14 +17,16 @@ export type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
setAccessControlAttributesSetting: (value: number) => void; setAccessControlAttributesSetting: (value: number) => void;
setAccessControlMembersSetting: (value: number) => void; setAccessControlMembersSetting: (value: number) => void;
setAnnouncementsOnly: (value: boolean) => void;
}; };
export const GroupV2Permissions: React.ComponentType<PropsType> = ({ export const GroupV2Permissions = ({
conversation, conversation,
i18n, i18n,
setAccessControlAttributesSetting, setAccessControlAttributesSetting,
setAccessControlMembersSetting, setAccessControlMembersSetting,
}) => { setAnnouncementsOnly,
}: PropsType): JSX.Element => {
if (conversation === undefined) { if (conversation === undefined) {
throw new Error('GroupV2Permissions rendered without a conversation'); throw new Error('GroupV2Permissions rendered without a conversation');
} }
@ -34,7 +37,16 @@ export const GroupV2Permissions: React.ComponentType<PropsType> = ({
const updateAccessControlMembers = (value: string) => { const updateAccessControlMembers = (value: string) => {
setAccessControlMembersSetting(Number(value)); setAccessControlMembersSetting(Number(value));
}; };
const AccessControlEnum = Proto.AccessControl.AccessRequired;
const updateAnnouncementsOnly = (value: string) => {
setAnnouncementsOnly(Number(value) === AccessControlEnum.ADMINISTRATOR);
};
const accessControlOptions = getAccessControlOptions(i18n); const accessControlOptions = getAccessControlOptions(i18n);
const announcementsOnlyValue = String(
conversation.announcementsOnly
? AccessControlEnum.ADMINISTRATOR
: AccessControlEnum.MEMBER
);
return ( return (
<PanelSection> <PanelSection>
@ -60,6 +72,19 @@ export const GroupV2Permissions: React.ComponentType<PropsType> = ({
/> />
} }
/> />
{conversation.areWeAdmin && conversation.announcementsOnlyReady && (
<PanelRow
label={i18n('ConversationDetails--announcement-label')}
info={i18n('ConversationDetails--announcement-info')}
right={
<Select
onChange={updateAnnouncementsOnly}
options={accessControlOptions}
value={announcementsOnlyValue}
/>
}
/>
)}
</PanelSection> </PanelSection>
); );
}; };

View File

@ -850,6 +850,29 @@ export function renderChangeDetail(
} }
return renderString('GroupV2--description--change--unknown', i18n); 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); throw missingCaseError(detail);
} }

View File

@ -78,98 +78,102 @@ import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
export { joinViaLink } from './groups/joinViaLink'; export { joinViaLink } from './groups/joinViaLink';
export type GroupV2AccessCreateChangeType = { type GroupV2AccessCreateChangeType = {
type: 'create'; type: 'create';
}; };
export type GroupV2AccessAttributesChangeType = { type GroupV2AccessAttributesChangeType = {
type: 'access-attributes'; type: 'access-attributes';
newPrivilege: number; newPrivilege: number;
}; };
export type GroupV2AccessMembersChangeType = { type GroupV2AccessMembersChangeType = {
type: 'access-members'; type: 'access-members';
newPrivilege: number; newPrivilege: number;
}; };
export type GroupV2AccessInviteLinkChangeType = { type GroupV2AccessInviteLinkChangeType = {
type: 'access-invite-link'; type: 'access-invite-link';
newPrivilege: number; newPrivilege: number;
}; };
export type GroupV2AvatarChangeType = { type GroupV2AnnouncementsOnlyChangeType = {
type: 'announcements-only';
announcementsOnly: boolean;
};
type GroupV2AvatarChangeType = {
type: 'avatar'; type: 'avatar';
removed: boolean; removed: boolean;
}; };
export type GroupV2TitleChangeType = { type GroupV2TitleChangeType = {
type: 'title'; type: 'title';
// Allow for null, because the title could be removed entirely // Allow for null, because the title could be removed entirely
newTitle?: string; newTitle?: string;
}; };
export type GroupV2GroupLinkAddChangeType = { type GroupV2GroupLinkAddChangeType = {
type: 'group-link-add'; type: 'group-link-add';
privilege: number; privilege: number;
}; };
export type GroupV2GroupLinkResetChangeType = { type GroupV2GroupLinkResetChangeType = {
type: 'group-link-reset'; type: 'group-link-reset';
}; };
export type GroupV2GroupLinkRemoveChangeType = { type GroupV2GroupLinkRemoveChangeType = {
type: 'group-link-remove'; type: 'group-link-remove';
}; };
// No disappearing messages timer change type - message.expirationTimerUpdate used instead // No disappearing messages timer change type - message.expirationTimerUpdate used instead
export type GroupV2MemberAddChangeType = { type GroupV2MemberAddChangeType = {
type: 'member-add'; type: 'member-add';
conversationId: string; conversationId: string;
}; };
export type GroupV2MemberAddFromInviteChangeType = { type GroupV2MemberAddFromInviteChangeType = {
type: 'member-add-from-invite'; type: 'member-add-from-invite';
conversationId: string; conversationId: string;
inviter?: string; inviter?: string;
}; };
export type GroupV2MemberAddFromLinkChangeType = { type GroupV2MemberAddFromLinkChangeType = {
type: 'member-add-from-link'; type: 'member-add-from-link';
conversationId: string; conversationId: string;
}; };
export type GroupV2MemberAddFromAdminApprovalChangeType = { type GroupV2MemberAddFromAdminApprovalChangeType = {
type: 'member-add-from-admin-approval'; type: 'member-add-from-admin-approval';
conversationId: string; conversationId: string;
}; };
export type GroupV2MemberPrivilegeChangeType = { type GroupV2MemberPrivilegeChangeType = {
type: 'member-privilege'; type: 'member-privilege';
conversationId: string; conversationId: string;
newPrivilege: number; newPrivilege: number;
}; };
export type GroupV2MemberRemoveChangeType = { type GroupV2MemberRemoveChangeType = {
type: 'member-remove'; type: 'member-remove';
conversationId: string; conversationId: string;
}; };
export type GroupV2PendingAddOneChangeType = { type GroupV2PendingAddOneChangeType = {
type: 'pending-add-one'; type: 'pending-add-one';
conversationId: string; conversationId: string;
}; };
export type GroupV2PendingAddManyChangeType = { type GroupV2PendingAddManyChangeType = {
type: 'pending-add-many'; type: 'pending-add-many';
count: number; count: number;
}; };
// Note: pending-remove is only used if user didn't also join the group at the same time // 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'; type: 'pending-remove-one';
conversationId: string; conversationId: string;
inviter?: string; inviter?: string;
}; };
// Note: pending-remove is only used if user didn't also join the group at the same time // 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'; type: 'pending-remove-many';
count: number; count: number;
inviter?: string; inviter?: string;
}; };
export type GroupV2AdminApprovalAddOneChangeType = { type GroupV2AdminApprovalAddOneChangeType = {
type: 'admin-approval-add-one'; type: 'admin-approval-add-one';
conversationId: string; conversationId: string;
}; };
// Note: admin-approval-remove-one is only used if user didn't also join the group at // Note: admin-approval-remove-one is only used if user didn't also join the group at
// the same time // the same time
export type GroupV2AdminApprovalRemoveOneChangeType = { type GroupV2AdminApprovalRemoveOneChangeType = {
type: 'admin-approval-remove-one'; type: 'admin-approval-remove-one';
conversationId: string; conversationId: string;
inviter?: string; inviter?: string;
@ -188,6 +192,7 @@ export type GroupV2ChangeDetailType =
| GroupV2AccessMembersChangeType | GroupV2AccessMembersChangeType
| GroupV2AdminApprovalAddOneChangeType | GroupV2AdminApprovalAddOneChangeType
| GroupV2AdminApprovalRemoveOneChangeType | GroupV2AdminApprovalRemoveOneChangeType
| GroupV2AnnouncementsOnlyChangeType
| GroupV2AvatarChangeType | GroupV2AvatarChangeType
| GroupV2DescriptionChangeType | GroupV2DescriptionChangeType
| GroupV2GroupLinkAddChangeType | GroupV2GroupLinkAddChangeType
@ -901,6 +906,20 @@ export function buildAccessControlAddFromInviteLinkChange(
return actions; 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( export function buildAccessControlAttributesChange(
group: ConversationAttributesType, group: ConversationAttributesType,
value: AccessRequiredEnum 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 // final processing
let message: MessageAttributesType | undefined; let message: MessageAttributesType | undefined;

1
ts/model-types.d.ts vendored
View File

@ -290,6 +290,7 @@ export type ConversationAttributesType = {
members: AccessRequiredEnum; members: AccessRequiredEnum;
addFromInviteLink: AccessRequiredEnum; addFromInviteLink: AccessRequiredEnum;
}; };
announcementsOnly?: boolean;
avatar?: { avatar?: {
url: string; url: string;
path: string; path: string;

View File

@ -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 // We need the user's profileKeyCredential, which requires a roundtrip with the
// server, and most definitely their profileKey. A getProfiles() call will // 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. // 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 // We need the user's profileKeyCredential, which requires a roundtrip with the
// server, and most definitely their profileKey. A getProfiles() call will // 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. // 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, ?.addFromInviteLink,
accessControlAttributes: this.get('accessControl')?.attributes, accessControlAttributes: this.get('accessControl')?.attributes,
accessControlMembers: this.get('accessControl')?.members, accessControlMembers: this.get('accessControl')?.members,
announcementsOnly: Boolean(this.get('announcementsOnly')),
announcementsOnlyReady: this.canBeAnnouncementGroup(),
expireTimer: this.get('expireTimer'), expireTimer: this.get('expireTimer'),
muteExpiresAt: this.get('muteExpiresAt')!, muteExpiresAt: this.get('muteExpiresAt')!,
name: this.get('name')!, name: this.get('name')!,
@ -1830,6 +1852,20 @@ export class ConversationModel extends window.Backbone
} }
async addMembersV2(conversationIds: ReadonlyArray<string>): Promise<void> { async addMembersV2(conversationIds: ReadonlyArray<string>): Promise<void> {
if (this.get('announcementsOnly')) {
const isEveryMemberCapable = conversationIds.every(conversationId => {
const model = window.ConversationController.get(conversationId);
return Boolean(model?.get('capabilities')?.announcementGroup);
});
if (!isEveryMemberCapable) {
const error = new Error(
'addMembersV2: some or all members need to upgrade.'
);
error.code = 'E_NO_CAPABILITY';
throw error;
}
}
await this.modifyGroupV2({ await this.modifyGroupV2({
name: 'addMembersV2', name: 'addMembersV2',
createGroupChange: () => createGroupChange: () =>
@ -2975,6 +3011,17 @@ export class ConversationModel extends window.Backbone
); );
} }
canBeAnnouncementGroup(): boolean {
if (!isGroupV2(this.attributes)) {
return false;
}
const members = getConversationMembers(this.attributes);
return members.every(conversationAttrs =>
Boolean(conversationAttrs.capabilities?.announcementGroup)
);
}
getMemberIds(): Array<string> { getMemberIds(): Array<string> {
const members = this.getMembers(); const members = this.getMembers();
return members.map(member => member.id); return members.map(member => member.id);
@ -4004,6 +4051,23 @@ export class ConversationModel extends window.Backbone
}); });
} }
async updateAnnouncementsOnly(value: boolean): Promise<void> {
if (!isGroupV2(this.attributes) || !this.canBeAnnouncementGroup()) {
return;
}
await this.modifyGroupV2({
name: 'updateAnnouncementsOnly',
createGroupChange: async () =>
window.Signal.Groups.buildAnnouncementsOnlyChange(
this.attributes,
value
),
});
this.set({ announcementsOnly: value });
}
async updateExpirationTimer( async updateExpirationTimer(
providedExpireTimer: number | undefined, providedExpireTimer: number | undefined,
providedSource?: unknown, providedSource?: unknown,
@ -5150,6 +5214,12 @@ export class ConversationModel extends window.Backbone
return; 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}`; const typingToken = `${senderId}.${senderDevice}`;
this.contactTypingTimers = this.contactTypingTimers || {}; this.contactTypingTimers = this.contactTypingTimers || {};

View File

@ -2737,6 +2737,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return; 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(); const messageId = window.getGuid();
// Send delivery receipts, but only for incoming sealed sender messages // Send delivery receipts, but only for incoming sealed sender messages

View File

@ -121,6 +121,8 @@ export type ConversationType = {
accessControlAddFromInviteLink?: number; accessControlAddFromInviteLink?: number;
accessControlAttributes?: number; accessControlAttributes?: number;
accessControlMembers?: number; accessControlMembers?: number;
announcementsOnly?: boolean;
announcementsOnlyReady?: boolean;
expireTimer?: number; expireTimer?: number;
memberships?: Array<{ memberships?: Array<{
conversationId: string; conversationId: string;

View File

@ -26,6 +26,7 @@ import { isConversationUnregistered } from '../../util/isConversationUnregistere
import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConversations'; import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConversations';
import { ContactNameColors, ContactNameColorType } from '../../types/Colors'; import { ContactNameColors, ContactNameColorType } from '../../types/Colors';
import { isInSystemContacts } from '../../util/isInSystemContacts'; import { isInSystemContacts } from '../../util/isInSystemContacts';
import { isGroupV2 } from '../../util/whatTypeOfConversation';
import { import {
getIntl, getIntl,
@ -894,3 +895,32 @@ export function isMissingRequiredProfileSharing(
conversation.messageCount > 0 conversation.messageCount > 0
); );
} }
export const getGroupAdminsSelector = createSelector(
getConversationSelector,
(conversationSelector: GetConversationByIdType) => {
return (conversationId: string): Array<ConversationType> => {
const { groupId, groupVersion, memberships = [] } = conversationSelector(
conversationId
);
if (
!isGroupV2({
groupId,
groupVersion,
})
) {
return [];
}
const admins: Array<ConversationType> = [];
memberships.forEach(membership => {
if (membership.isAdmin) {
const admin = conversationSelector(membership.conversationId);
admins.push(admin);
}
});
return admins;
};
}
);

View File

@ -1133,6 +1133,11 @@ export function canReply(
return false; 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 // We can reply if this is outgoing and sent to at least one recipient
if (isOutgoing(message)) { if (isOutgoing(message)) {
return ( return (

View File

@ -12,6 +12,7 @@ import { selectRecentEmojis } from '../selectors/emojis';
import { getIntl, getUserConversationId } from '../selectors/user'; import { getIntl, getUserConversationId } from '../selectors/user';
import { import {
getConversationSelector, getConversationSelector,
getGroupAdminsSelector,
isMissingRequiredProfileSharing, isMissingRequiredProfileSharing,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { getPropsForQuote } from '../selectors/message'; import { getPropsForQuote } from '../selectors/message';
@ -38,7 +39,12 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
throw new Error(`Conversation id ${id} not found!`); throw new Error(`Conversation id ${id} not found!`);
} }
const { draftText, draftBodyRanges } = conversation; const {
announcementsOnly,
areWeAdmin,
draftText,
draftBodyRanges,
} = conversation;
const receivedPacks = getReceivedStickerPacks(state); const receivedPacks = getReceivedStickerPacks(state);
const installedPacks = getInstalledStickerPacks(state); const installedPacks = getInstalledStickerPacks(state);
@ -109,6 +115,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
isMissingMandatoryProfileSharing: isMissingRequiredProfileSharing( isMissingMandatoryProfileSharing: isMissingRequiredProfileSharing(
conversation conversation
), ),
// Groups
announcementsOnly,
areWeAdmin,
groupAdmins: getGroupAdminsSelector(state)(conversation.id),
}; };
}; };

View File

@ -93,10 +93,13 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
return { return {
...pick(conversation, [ ...pick(conversation, [
'acceptedMessageRequest', 'acceptedMessageRequest',
'announcementsOnly',
'areWeAdmin',
'avatarPath', 'avatarPath',
'canChangeTimer', 'canChangeTimer',
'color', 'color',
'expireTimer', 'expireTimer',
'groupVersion',
'isArchived', 'isArchived',
'isMe', 'isMe',
'isPinned', 'isPinned',
@ -107,10 +110,9 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
'name', 'name',
'phoneNumber', 'phoneNumber',
'profileName', 'profileName',
'sharedGroupNames',
'title', 'title',
'type', 'type',
'groupVersion',
'sharedGroupNames',
'unblurredAvatarPath', 'unblurredAvatarPath',
]), ]),
conversationTitle: state.conversations.selectedConversationTitle, conversationTitle: state.conversations.selectedConversationTitle,

View File

@ -15,6 +15,7 @@ export type SmartGroupV2PermissionsProps = {
conversationId: string; conversationId: string;
setAccessControlAttributesSetting: (value: number) => void; setAccessControlAttributesSetting: (value: number) => void;
setAccessControlMembersSetting: (value: number) => void; setAccessControlMembersSetting: (value: number) => void;
setAnnouncementsOnly: (value: boolean) => void;
}; };
const mapStateToProps = ( const mapStateToProps = (

View File

@ -892,11 +892,13 @@ export type WebAPIConnectType = {
}; };
export type CapabilitiesType = { export type CapabilitiesType = {
announcementGroup: boolean;
gv2: boolean; gv2: boolean;
'gv1-migration': boolean; 'gv1-migration': boolean;
senderKey: boolean; senderKey: boolean;
}; };
export type CapabilitiesUploadType = { export type CapabilitiesUploadType = {
announcementGroup: boolean;
'gv2-3': boolean; 'gv2-3': boolean;
'gv1-migration': boolean; 'gv1-migration': boolean;
senderKey: boolean; senderKey: boolean;
@ -1558,6 +1560,7 @@ export function initialize({
options: { accessKey?: ArrayBuffer } = {} options: { accessKey?: ArrayBuffer } = {}
) { ) {
const capabilities: CapabilitiesUploadType = { const capabilities: CapabilitiesUploadType = {
announcementGroup: true,
'gv2-3': true, 'gv2-3': true,
'gv1-migration': true, 'gv1-migration': true,
senderKey: false, senderKey: false,

View File

@ -25,7 +25,7 @@ export function isMe(conversationAttrs: ConversationAttributesType): boolean {
} }
export function isGroupV1( export function isGroupV1(
conversationAttrs: ConversationAttributesType conversationAttrs: Pick<ConversationAttributesType, 'groupId'>
): boolean { ): boolean {
const { groupId } = conversationAttrs; const { groupId } = conversationAttrs;
if (!groupId) { if (!groupId) {
@ -37,7 +37,10 @@ export function isGroupV1(
} }
export function isGroupV2( export function isGroupV2(
conversationAttrs: ConversationAttributesType conversationAttrs: Pick<
ConversationAttributesType,
'groupId' | 'groupVersion'
>
): boolean { ): boolean {
const { groupId, groupVersion = 0 } = conversationAttrs; const { groupId, groupVersion = 0 } = conversationAttrs;
if (!groupId) { if (!groupId) {

View File

@ -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({ Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({
template: () => window.i18n('dangerousFileType'), template: () => window.i18n('dangerousFileType'),
}); });
@ -555,6 +559,11 @@ Whisper.ConversationView = Whisper.View.extend({
); );
const isVideoCall = true; const isVideoCall = true;
if (model.get('announcementsOnly') && !model.areWeAdmin()) {
this.showToast(Whisper.CannotStartGroupCallToast);
return;
}
if (await this.isCallSafe()) { if (await this.isCallSafe()) {
window.log.info( window.log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call' 'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
@ -722,6 +731,8 @@ Whisper.ConversationView = Whisper.View.extend({
this.disableLinkPreviews = true; this.disableLinkPreviews = true;
this.removeLinkPreview(); this.removeLinkPreview();
}, },
openConversation: this.openConversation.bind(this),
}; };
this.compositionAreaView = new Whisper.ReactWrapperView({ this.compositionAreaView = new Whisper.ReactWrapperView({
@ -733,7 +744,7 @@ Whisper.ConversationView = Whisper.View.extend({
}); });
// Finally, add it to the DOM // Finally, add it to the DOM
this.$('.composition-area-placeholder').append(this.compositionAreaView.el); this.$('.CompositionArea__placeholder').append(this.compositionAreaView.el);
}, },
async longRunningTaskWrapper<T>({ async longRunningTaskWrapper<T>({
@ -2316,6 +2327,7 @@ Whisper.ConversationView = Whisper.View.extend({
includedAttachments?: Array<AttachmentType>, includedAttachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType linkPreview?: LinkPreviewType
) => { ) => {
try {
const didForwardSuccessfully = await this.maybeForwardMessage( const didForwardSuccessfully = await this.maybeForwardMessage(
message, message,
conversationIds, conversationIds,
@ -2328,6 +2340,12 @@ Whisper.ConversationView = Whisper.View.extend({
this.forwardMessageModal.remove(); this.forwardMessageModal.remove();
this.forwardMessageModal = null; this.forwardMessageModal = null;
} }
} catch (err) {
window.log.warn(
'doForwardMessage',
err && err.stack ? err.stack : err
);
}
}, },
isSticker: Boolean(message.get('sticker')), isSticker: Boolean(message.get('sticker')),
messageBody: message.getRawText(), messageBody: message.getRawText(),
@ -2380,6 +2398,14 @@ Whisper.ConversationView = Whisper.View.extend({
window.ConversationController.get(id) 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 // Verify that all contacts that we're forwarding
// to are verified and trusted // to are verified and trusted
const unverifiedContacts: Array<ConversationModel> = []; const unverifiedContacts: Array<ConversationModel> = [];
@ -3253,6 +3279,7 @@ Whisper.ConversationView = Whisper.View.extend({
setAccessControlMembersSetting: this.setAccessControlMembersSetting.bind( setAccessControlMembersSetting: this.setAccessControlMembersSetting.bind(
this this
), ),
setAnnouncementsOnly: this.setAnnouncementsOnly.bind(this),
} }
), ),
}); });
@ -3301,6 +3328,10 @@ Whisper.ConversationView = Whisper.View.extend({
}, },
showConversationDetails() { 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 { model }: { model: ConversationModel } = this;
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
@ -3593,7 +3624,9 @@ Whisper.ConversationView = Whisper.View.extend({
}); });
}, },
async setAccessControlAddFromInviteLinkSetting(value: boolean) { async setAccessControlAddFromInviteLinkSetting(
value: boolean
): Promise<void> {
const { model }: { model: ConversationModel } = this; const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({ await this.longRunningTaskWrapper({
@ -3602,7 +3635,7 @@ Whisper.ConversationView = Whisper.View.extend({
}); });
}, },
async setAccessControlAttributesSetting(value: number) { async setAccessControlAttributesSetting(value: number): Promise<void> {
const { model }: { model: ConversationModel } = this; const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({ await this.longRunningTaskWrapper({
@ -3611,7 +3644,7 @@ Whisper.ConversationView = Whisper.View.extend({
}); });
}, },
async setAccessControlMembersSetting(value: number) { async setAccessControlMembersSetting(value: number): Promise<void> {
const { model }: { model: ConversationModel } = this; const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({ await this.longRunningTaskWrapper({
@ -3620,6 +3653,15 @@ Whisper.ConversationView = Whisper.View.extend({
}); });
}, },
async setAnnouncementsOnly(value: boolean): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAnnouncementsOnly',
task: async () => model.updateAnnouncementsOnly(value),
});
},
async destroyMessages() { async destroyMessages() {
const { model }: { model: ConversationModel } = this; const { model }: { model: ConversationModel } = this;

1
ts/window.d.ts vendored
View File

@ -638,6 +638,7 @@ export type WhisperType = {
CannotMixImageAndNonImageAttachmentsToast: typeof window.Whisper.ToastView; CannotMixImageAndNonImageAttachmentsToast: typeof window.Whisper.ToastView;
CaptchaSolvedToast: typeof window.Whisper.ToastView; CaptchaSolvedToast: typeof window.Whisper.ToastView;
CaptchaFailedToast: typeof window.Whisper.ToastView; CaptchaFailedToast: typeof window.Whisper.ToastView;
CannotStartGroupCallToast: typeof window.Whisper.ToastView;
DangerousFileTypeToast: typeof window.Whisper.ToastView; DangerousFileTypeToast: typeof window.Whisper.ToastView;
DecryptionErrorToast: typeof window.Whisper.ToastView; DecryptionErrorToast: typeof window.Whisper.ToastView;
ExpiredToast: typeof window.Whisper.ToastView; ExpiredToast: typeof window.Whisper.ToastView;