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

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