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"
},
"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"
}
}

View file

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

View file

@ -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;
}
}

View file

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

View file

@ -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

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
package signalservice;

View file

@ -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";

View file

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

View file

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

View file

@ -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;

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 {

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;
}
&--show-disabled {
opacity: 0.5;
}
@include light-theme {
&:hover,
&:focus {

View file

@ -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';

View file

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

View file

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

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: '',
// GroupV1 Disabled Actions
onStartGroupMigration: action('onStartGroupMigration'),
// GroupV2 Pending Approval Actions
// GroupV2
announcementsOnly: boolean(
'announcementsOnly',
Boolean(overrideProps.announcementsOnly)
),
areWeAdmin: boolean('areWeAdmin', Boolean(overrideProps.areWeAdmin)),
groupAdmins: [],
openConversation: action('openConversation'),
onCancelJoinRequest: action('onCancelJoinRequest'),
// SMS-only
isSMSOnly: overrideProps.isSMSOnly || false,
@ -157,3 +164,12 @@ story.add('Attachments', () => {
return <CompositionArea {...props} />;
});
story.add('Announcements Only group', () => (
<CompositionArea
{...createProps({
announcementsOnly: true,
areWeAdmin: false,
})}
/>
));

View file

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

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 { Button } from './Button';
import { CompositionInput, InputApi } from './CompositionInput';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import { ConversationList, Row, RowType } from './ConversationList';
import { ConversationType } from '../state/ducks/conversations';
@ -92,6 +93,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
const [attachmentsToForward, setAttachmentsToForward] = useState(attachments);
const [isEditingMessage, setIsEditingMessage] = useState(false);
const [messageBodyText, setMessageBodyText] = useState(messageBody || '');
const [cannotMessage, setCannotMessage] = useState(false);
const isMessageEditable = !isSticker;
@ -186,7 +188,11 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
}
const selectedContact = contactLookup.get(conversationId);
if (selectedContact) {
setSelectedContacts([...nextSelectedContacts, selectedContact]);
if (selectedContact.announcementsOnly && !selectedContact.areWeAdmin) {
setCannotMessage(true);
} else {
setSelectedContacts([...nextSelectedContacts, selectedContact]);
}
}
},
[contactLookup, selectedContacts, setSelectedContacts]
@ -233,183 +239,194 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
}, []);
return (
<ModalHost onEscape={handleBackOrClose} onClose={onClose}>
<div className="module-ForwardMessageModal">
<div
className={classNames('module-ForwardMessageModal__header', {
'module-ForwardMessageModal__header--edit': isEditingMessage,
})}
<>
{cannotMessage && (
<ConfirmationDialog
cancelText={i18n('Confirmation--confirm')}
i18n={i18n}
onClose={() => setCannotMessage(false)}
>
{i18n('GroupV2--cannot-send')}
</ConfirmationDialog>
)}
<ModalHost onEscape={handleBackOrClose} onClose={onClose}>
<div className="module-ForwardMessageModal">
<div
className={classNames('module-ForwardMessageModal__header', {
'module-ForwardMessageModal__header--edit': isEditingMessage,
})}
>
{isEditingMessage ? (
<button
aria-label={i18n('back')}
className="module-ForwardMessageModal__header--back"
onClick={() => setIsEditingMessage(false)}
type="button"
>
&nbsp;
</button>
) : (
<button
aria-label={i18n('close')}
className="module-ForwardMessageModal__header--close"
onClick={onClose}
type="button"
/>
)}
<h1>{i18n('forwardMessage')}</h1>
</div>
{isEditingMessage ? (
<button
aria-label={i18n('back')}
className="module-ForwardMessageModal__header--back"
onClick={() => setIsEditingMessage(false)}
type="button"
>
&nbsp;
</button>
) : (
<button
aria-label={i18n('close')}
className="module-ForwardMessageModal__header--close"
onClick={onClose}
type="button"
/>
)}
<h1>{i18n('forwardMessage')}</h1>
</div>
{isEditingMessage ? (
<div className="module-ForwardMessageModal__main-body">
{linkPreview ? (
<div className="module-ForwardMessageModal--link-preview">
<StagedLinkPreview
date={linkPreview.date || null}
description={linkPreview.description || ''}
domain={linkPreview.url}
<div className="module-ForwardMessageModal__main-body">
{linkPreview ? (
<div className="module-ForwardMessageModal--link-preview">
<StagedLinkPreview
date={linkPreview.date || null}
description={linkPreview.description || ''}
domain={linkPreview.url}
i18n={i18n}
image={linkPreview.image}
onClose={() => removeLinkPreview()}
title={linkPreview.title}
/>
</div>
) : null}
{attachmentsToForward && attachmentsToForward.length ? (
<AttachmentList
attachments={attachmentsToForward}
i18n={i18n}
image={linkPreview.image}
onClose={() => removeLinkPreview()}
title={linkPreview.title}
onCloseAttachment={(attachment: AttachmentType) => {
const newAttachments = attachmentsToForward.filter(
currentAttachment => currentAttachment !== attachment
);
setAttachmentsToForward(newAttachments);
}}
/>
</div>
) : null}
{attachmentsToForward && attachmentsToForward.length ? (
<AttachmentList
attachments={attachmentsToForward}
i18n={i18n}
onCloseAttachment={(attachment: AttachmentType) => {
const newAttachments = attachmentsToForward.filter(
currentAttachment => currentAttachment !== attachment
);
setAttachmentsToForward(newAttachments);
}}
/>
) : null}
<div className="module-ForwardMessageModal__text-edit-area">
<CompositionInput
clearQuotedMessage={shouldNeverBeCalled}
draftText={messageBodyText}
getQuotedMessage={noop}
i18n={i18n}
inputApi={inputApiRef}
large
moduleClassName="module-ForwardMessageModal__input"
onEditorStateChange={(
messageText,
bodyRanges,
caretLocation
) => {
setMessageBodyText(messageText);
onEditorStateChange(messageText, bodyRanges, caretLocation);
}}
onPickEmoji={onPickEmoji}
onSubmit={forwardMessage}
onTextTooLong={onTextTooLong}
/>
<div className="module-ForwardMessageModal__emoji">
<EmojiButton
) : null}
<div className="module-ForwardMessageModal__text-edit-area">
<CompositionInput
clearQuotedMessage={shouldNeverBeCalled}
draftText={messageBodyText}
getQuotedMessage={noop}
i18n={i18n}
onClose={focusTextEditInput}
onPickEmoji={insertEmoji}
onSetSkinTone={onSetSkinTone}
recentEmojis={recentEmojis}
skinTone={skinTone}
inputApi={inputApiRef}
large
moduleClassName="module-ForwardMessageModal__input"
onEditorStateChange={(
messageText,
bodyRanges,
caretLocation
) => {
setMessageBodyText(messageText);
onEditorStateChange(messageText, bodyRanges, caretLocation);
}}
onPickEmoji={onPickEmoji}
onSubmit={forwardMessage}
onTextTooLong={onTextTooLong}
/>
<div className="module-ForwardMessageModal__emoji">
<EmojiButton
i18n={i18n}
onClose={focusTextEditInput}
onPickEmoji={insertEmoji}
onSetSkinTone={onSetSkinTone}
recentEmojis={recentEmojis}
skinTone={skinTone}
/>
</div>
</div>
</div>
</div>
) : (
<div className="module-ForwardMessageModal__main-body">
<SearchInput
disabled={candidateConversations.length === 0}
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
ref={inputRef}
value={searchTerm}
/>
{candidateConversations.length ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => {
// We disable this ESLint rule because we're capturing a bubbled keydown
// event. See [this note in the jsx-a11y docs][0].
//
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<div
className="module-ForwardMessageModal__list-wrapper"
ref={measureRef}
>
<ConversationList
dimensions={contentRect.bounds}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={(
conversationId: string,
disabledReason:
| undefined
| ContactCheckboxDisabledReason
) => {
if (
disabledReason !==
ContactCheckboxDisabledReason.MaximumContactsSelected
) {
toggleSelectedConversation(conversationId);
}
}}
onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={
shouldNeverBeCalled
}
/>
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
) : (
<div className="module-ForwardMessageModal__main-body">
<SearchInput
disabled={candidateConversations.length === 0}
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
</Measure>
) : (
<div className="module-ForwardMessageModal__no-candidate-contacts">
{i18n('noContactsFound')}
</div>
)}
</div>
)}
<div className="module-ForwardMessageModal__footer">
<div>
{Boolean(selectedContacts.length) &&
selectedContacts.map(contact => contact.title).join(', ')}
</div>
<div>
{isEditingMessage || !isMessageEditable ? (
<Button
aria-label={i18n('ForwardMessageModal--continue')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
disabled={!canForwardMessage}
onClick={forwardMessage}
ref={inputRef}
value={searchTerm}
/>
) : (
<Button
aria-label={i18n('forwardMessage')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--continue"
disabled={!hasContactsSelected}
onClick={() => setIsEditingMessage(true)}
/>
)}
{candidateConversations.length ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => {
// We disable this ESLint rule because we're capturing a bubbled
// keydown event. See [this note in the jsx-a11y docs][0].
//
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<div
className="module-ForwardMessageModal__list-wrapper"
ref={measureRef}
>
<ConversationList
dimensions={contentRect.bounds}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={(
conversationId: string,
disabledReason:
| undefined
| ContactCheckboxDisabledReason
) => {
if (
disabledReason !==
ContactCheckboxDisabledReason.MaximumContactsSelected
) {
toggleSelectedConversation(conversationId);
}
}}
onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={
shouldNeverBeCalled
}
/>
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}}
</Measure>
) : (
<div className="module-ForwardMessageModal__no-candidate-contacts">
{i18n('noContactsFound')}
</div>
)}
</div>
)}
<div className="module-ForwardMessageModal__footer">
<div>
{Boolean(selectedContacts.length) &&
selectedContacts.map(contact => contact.title).join(', ')}
</div>
<div>
{isEditingMessage || !isMessageEditable ? (
<Button
aria-label={i18n('ForwardMessageModal--continue')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
disabled={!canForwardMessage}
onClick={forwardMessage}
/>
) : (
<Button
aria-label={i18n('forwardMessage')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--continue"
disabled={!hasContactsSelected}
onClick={() => setIsEditingMessage(true)}
/>
)}
</div>
</div>
</div>
</div>
</ModalHost>
</ModalHost>
</>
);
};

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

@ -290,6 +290,7 @@ export type ConversationAttributesType = {
members: AccessRequiredEnum;
addFromInviteLink: AccessRequiredEnum;
};
announcementsOnly?: boolean;
avatar?: {
url: 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
// server, and most definitely their profileKey. A getProfiles() call will
// ensure that we have as much as we can get with the data we have.
@ -585,6 +595,16 @@ export class ConversationModel extends window.Backbone
);
}
if (
this.get('announcementsOnly') &&
!toRequest.get('capabilities')?.announcementGroup
) {
window.log.warn(
`addMember/${idLog}: ${conversationId} needs to upgrade.`
);
return undefined;
}
// We need the user's profileKeyCredential, which requires a roundtrip with the
// server, and most definitely their profileKey. A getProfiles() call will
// ensure that we have as much as we can get with the data we have.
@ -1436,6 +1456,8 @@ export class ConversationModel extends window.Backbone
?.addFromInviteLink,
accessControlAttributes: this.get('accessControl')?.attributes,
accessControlMembers: this.get('accessControl')?.members,
announcementsOnly: Boolean(this.get('announcementsOnly')),
announcementsOnlyReady: this.canBeAnnouncementGroup(),
expireTimer: this.get('expireTimer'),
muteExpiresAt: this.get('muteExpiresAt')!,
name: this.get('name')!,
@ -1830,6 +1852,20 @@ export class ConversationModel extends window.Backbone
}
async addMembersV2(conversationIds: ReadonlyArray<string>): Promise<void> {
if (this.get('announcementsOnly')) {
const isEveryMemberCapable = conversationIds.every(conversationId => {
const model = window.ConversationController.get(conversationId);
return Boolean(model?.get('capabilities')?.announcementGroup);
});
if (!isEveryMemberCapable) {
const error = new Error(
'addMembersV2: some or all members need to upgrade.'
);
error.code = 'E_NO_CAPABILITY';
throw error;
}
}
await this.modifyGroupV2({
name: 'addMembersV2',
createGroupChange: () =>
@ -2975,6 +3011,17 @@ export class ConversationModel extends window.Backbone
);
}
canBeAnnouncementGroup(): boolean {
if (!isGroupV2(this.attributes)) {
return false;
}
const members = getConversationMembers(this.attributes);
return members.every(conversationAttrs =>
Boolean(conversationAttrs.capabilities?.announcementGroup)
);
}
getMemberIds(): Array<string> {
const members = this.getMembers();
return members.map(member => member.id);
@ -4004,6 +4051,23 @@ export class ConversationModel extends window.Backbone
});
}
async updateAnnouncementsOnly(value: boolean): Promise<void> {
if (!isGroupV2(this.attributes) || !this.canBeAnnouncementGroup()) {
return;
}
await this.modifyGroupV2({
name: 'updateAnnouncementsOnly',
createGroupChange: async () =>
window.Signal.Groups.buildAnnouncementsOnlyChange(
this.attributes,
value
),
});
this.set({ announcementsOnly: value });
}
async updateExpirationTimer(
providedExpireTimer: number | undefined,
providedSource?: unknown,
@ -5150,6 +5214,12 @@ export class ConversationModel extends window.Backbone
return;
}
// Drop typing indicators for announcement only groups where the sender
// is not an admin
if (this.get('announcementsOnly') && !this.isAdmin(senderId)) {
return;
}
const typingToken = `${senderId}.${senderDevice}`;
this.contactTypingTimers = this.contactTypingTimers || {};

View file

@ -2737,6 +2737,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return;
}
// Drop incoming messages to announcement only groups where sender is not admin
if (
conversation.get('announcementsOnly') &&
!conversation.isAdmin(senderId)
) {
confirm();
return;
}
const messageId = window.getGuid();
// Send delivery receipts, but only for incoming sealed sender messages

View file

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

View file

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

View file

@ -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 (

View file

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

View file

@ -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,

View file

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

View file

@ -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,

View file

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

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

1
ts/window.d.ts vendored
View file

@ -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;