Group Description: Edit/Receive

This commit is contained in:
Scott Nonnenberg 2021-06-01 17:24:28 -07:00 committed by GitHub
parent e5d365dfc4
commit 9705f464be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 859 additions and 149 deletions

View file

@ -0,0 +1,151 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
ClipboardEvent,
forwardRef,
useEffect,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
import { multiRef } from '../util/multiRef';
import * as grapheme from '../util/grapheme';
const MAX_GRAPHEME_COUNT = 256;
const SHOW_REMAINING_COUNT = 150;
type PropsType = {
disabled?: boolean;
i18n: LocalizerType;
onChangeValue: (value: string) => void;
value: string;
};
/**
* Group titles must have fewer than MAX_GRAPHEME_COUNT glyphs. Ideally, we'd use the
* `maxLength` property on inputs, but that doesn't account for glyphs that are more than
* one UTF-16 code units. For example: `'💩💩'.length === 4`.
*
* This component effectively implements a "max grapheme length" on an input.
*
* At a high level, this component handles two methods of input:
*
* - `onChange`. *Before* the value is changed (in `onKeyDown`), we save the value and the
* cursor position. Then, in `onChange`, we see if the new value is too long. If it is,
* we revert the value and selection. Otherwise, we fire `onChangeValue`.
*
* - `onPaste`. If you're pasting something that will fit, we fall back to normal browser
* behavior, which calls `onChange`. If you're pasting something that won't fit, it's a
* noop.
*/
export const GroupDescriptionInput = forwardRef<HTMLTextAreaElement, PropsType>(
({ i18n, disabled = false, onChangeValue, value }, ref) => {
const innerRef = useRef<HTMLTextAreaElement | null>(null);
const valueOnKeydownRef = useRef<string>(value);
const selectionStartOnKeydownRef = useRef<number>(value.length);
const [isLarge, setIsLarge] = useState(false);
function maybeSetLarge() {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
if (inputEl.scrollHeight > inputEl.clientHeight) {
setIsLarge(true);
}
}
const onKeyDown = () => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
valueOnKeydownRef.current = inputEl.value;
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
};
const onChange = () => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
const newValue = inputEl.value;
const newGraphemeCount = grapheme.count(newValue);
if (newGraphemeCount <= MAX_GRAPHEME_COUNT) {
onChangeValue(newValue);
} else {
inputEl.value = valueOnKeydownRef.current;
inputEl.selectionStart = selectionStartOnKeydownRef.current;
inputEl.selectionEnd = selectionStartOnKeydownRef.current;
}
maybeSetLarge();
};
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
const selectionStart = inputEl.selectionStart || 0;
const selectionEnd = inputEl.selectionEnd || inputEl.selectionStart || 0;
const textBeforeSelection = value.slice(0, selectionStart);
const textAfterSelection = value.slice(selectionEnd);
const pastedText = event.clipboardData.getData('Text');
const newGraphemeCount =
grapheme.count(textBeforeSelection) +
grapheme.count(pastedText) +
grapheme.count(textAfterSelection);
if (newGraphemeCount > MAX_GRAPHEME_COUNT) {
event.preventDefault();
}
maybeSetLarge();
};
useEffect(() => {
maybeSetLarge();
}, []);
const graphemeCount = grapheme.count(value);
return (
<>
<div className="module-GroupInput--container module-GroupInput__description--container">
<textarea
className={classNames({
'module-GroupInput': true,
'module-GroupInput__description': true,
'module-GroupInput__description--large': isLarge,
})}
disabled={disabled}
onChange={onChange}
onKeyDown={onKeyDown}
onPaste={onPaste}
placeholder={i18n(
'setGroupMetadata__group-description-placeholder'
)}
ref={multiRef<HTMLTextAreaElement>(ref, innerRef)}
value={value}
/>
{graphemeCount >= SHOW_REMAINING_COUNT && (
<div className="module-GroupInput__description--remaining">
{MAX_GRAPHEME_COUNT - graphemeCount}
</div>
)}
</div>
</>
);
}
);

View file

@ -40,61 +40,63 @@ export const GroupTitleInput = forwardRef<HTMLInputElement, PropsType>(
const selectionStartOnKeydownRef = useRef<number>(value.length);
return (
<input
disabled={disabled}
className="module-GroupTitleInput"
onKeyDown={() => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
<div className="module-GroupInput--container">
<input
disabled={disabled}
className="module-GroupInput"
onKeyDown={() => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
valueOnKeydownRef.current = inputEl.value;
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
}}
onChange={() => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
valueOnKeydownRef.current = inputEl.value;
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
}}
onChange={() => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
const newValue = inputEl.value;
if (grapheme.count(newValue) <= MAX_GRAPHEME_COUNT) {
onChangeValue(newValue);
} else {
inputEl.value = valueOnKeydownRef.current;
inputEl.selectionStart = selectionStartOnKeydownRef.current;
inputEl.selectionEnd = selectionStartOnKeydownRef.current;
}
}}
onPaste={(event: ClipboardEvent<HTMLInputElement>) => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
const newValue = inputEl.value;
if (grapheme.count(newValue) <= MAX_GRAPHEME_COUNT) {
onChangeValue(newValue);
} else {
inputEl.value = valueOnKeydownRef.current;
inputEl.selectionStart = selectionStartOnKeydownRef.current;
inputEl.selectionEnd = selectionStartOnKeydownRef.current;
}
}}
onPaste={(event: ClipboardEvent<HTMLInputElement>) => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
const selectionStart = inputEl.selectionStart || 0;
const selectionEnd =
inputEl.selectionEnd || inputEl.selectionStart || 0;
const textBeforeSelection = value.slice(0, selectionStart);
const textAfterSelection = value.slice(selectionEnd);
const selectionStart = inputEl.selectionStart || 0;
const selectionEnd =
inputEl.selectionEnd || inputEl.selectionStart || 0;
const textBeforeSelection = value.slice(0, selectionStart);
const textAfterSelection = value.slice(selectionEnd);
const pastedText = event.clipboardData.getData('Text');
const pastedText = event.clipboardData.getData('Text');
const newGraphemeCount =
grapheme.count(textBeforeSelection) +
grapheme.count(pastedText) +
grapheme.count(textAfterSelection);
const newGraphemeCount =
grapheme.count(textBeforeSelection) +
grapheme.count(pastedText) +
grapheme.count(textAfterSelection);
if (newGraphemeCount > MAX_GRAPHEME_COUNT) {
event.preventDefault();
}
}}
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
ref={multiRef<HTMLInputElement>(ref, innerRef)}
type="text"
value={value}
/>
if (newGraphemeCount > MAX_GRAPHEME_COUNT) {
event.preventDefault();
}
}}
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
ref={multiRef<HTMLInputElement>(ref, innerRef)}
type="text"
value={value}
/>
</div>
);
}
);

View file

@ -20,6 +20,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
'approvalRequired',
overrideProps.approvalRequired || false
),
groupDescription: overrideProps.groupDescription,
join: action('join'),
onClose: action('onClose'),
i18n,
@ -78,3 +79,18 @@ stories.add('Avatar loading state', () => {
/>
);
});
stories.add('Full', () => {
return (
<GroupV2JoinDialog
{...createProps({
avatar: {
url: '/fixtures/giphy-GVNvOUpeYmI7e.gif',
},
memberCount: 16,
groupDescription: 'Discuss meets, events, training, and recruiting.',
title: 'Underwater basket weavers (LA)',
})}
/>
);
});

View file

@ -7,6 +7,7 @@ import { LocalizerType } from '../types/Util';
import { Avatar, AvatarBlur } from './Avatar';
import { Spinner } from './Spinner';
import { Button, ButtonVariant } from './Button';
import { GroupDescription } from './conversation/GroupDescription';
import { PreJoinConversationType } from '../state/ducks/conversations';
@ -35,6 +36,7 @@ export const GroupV2JoinDialog = React.memo((props: PropsType) => {
const {
approvalRequired,
avatar,
groupDescription,
i18n,
join,
memberCount,
@ -45,9 +47,6 @@ export const GroupV2JoinDialog = React.memo((props: PropsType) => {
const joinString = approvalRequired
? i18n('GroupV2--join--request-to-join-button')
: i18n('GroupV2--join--join-button');
const promptString = approvalRequired
? i18n('GroupV2--join--prompt-with-approval')
: i18n('GroupV2--join--prompt');
const memberString =
memberCount === 1
? i18n('GroupV2--join--member-count--single')
@ -93,7 +92,20 @@ export const GroupV2JoinDialog = React.memo((props: PropsType) => {
<div className="module-group-v2-join-dialog__metadata">
{i18n('GroupV2--join--group-metadata', [memberString])}
</div>
<div className="module-group-v2-join-dialog__prompt">{promptString}</div>
{groupDescription && (
<div className="module-group-v2-join-dialog__description">
<GroupDescription i18n={i18n} title={title} text={groupDescription} />
</div>
)}
{approvalRequired ? (
<div className="module-group-v2-join-dialog__prompt--approval">
{i18n('GroupV2--join--prompt-with-approval')}
</div>
) : (
<div className="module-group-v2-join-dialog__prompt">
{i18n('GroupV2--join--prompt')}
</div>
)}
<div className="module-group-v2-join-dialog__buttons">
<Button
className={classNames(

View file

@ -235,6 +235,26 @@ storiesOf('Components/Conversation/ConversationHero', module)
title={text('title', 'NYC Rock Climbers')}
name={text('groupName', 'NYC Rock Climbers')}
conversationType="group"
groupDescription="This is a group for all the rock climbers of NYC"
membersCount={0}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
})
.add('Group (long group description)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={text('title', 'NYC Rock Climbers')}
name={text('groupName', 'NYC Rock Climbers')}
conversationType="group"
groupDescription="This is a group for all the rock climbers of NYC. We really like to climb rocks and these NYC people climb any rock. No rock is too small or too big to be climbed. We will ascend upon all rocks, and not just in NYC, in the whole world. We are just getting started, NYC is just the beginning, watch out rocks in the galaxy. Kuiper belt I'm looking at you. We will put on a space suit and climb all your rocks. No rock is near nor far for the rock climbers of NYC."
membersCount={0}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}

View file

@ -6,6 +6,7 @@ import Measure from 'react-measure';
import { Avatar, AvatarBlur, Props as AvatarProps } from '../Avatar';
import { ContactName } from './ContactName';
import { About } from './About';
import { GroupDescription } from './GroupDescription';
import { SharedGroupNames } from '../SharedGroupNames';
import { LocalizerType } from '../../types/Util';
import { ConfirmationDialog } from '../ConfirmationDialog';
@ -16,6 +17,7 @@ import { shouldBlurAvatar } from '../../util/shouldBlurAvatar';
export type Props = {
about?: string;
acceptedMessageRequest?: boolean;
groupDescription?: string;
i18n: LocalizerType;
isMe: boolean;
membersCount?: number;
@ -97,6 +99,7 @@ export const ConversationHero = ({
avatarPath,
color,
conversationType,
groupDescription,
isMe,
membersCount,
sharedGroupNames = [],
@ -215,13 +218,19 @@ export const ConversationHero = ({
)}
{!isMe ? (
<div className="module-conversation-hero__with">
{membersCount === 1
? i18n('ConversationHero--members-1')
: membersCount !== undefined
? i18n('ConversationHero--members', [`${membersCount}`])
: phoneNumberOnly
? null
: phoneNumber}
{groupDescription ? (
<GroupDescription
i18n={i18n}
title={title}
text={groupDescription}
/>
) : membersCount === 1 ? (
i18n('ConversationHero--members-1')
) : membersCount !== undefined ? (
i18n('ConversationHero--members', [`${membersCount}`])
) : phoneNumberOnly ? null : (
phoneNumber
)}
</div>
) : null}
{renderMembershipRow({

View file

@ -0,0 +1,32 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { text } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { GroupDescription, PropsType } from './GroupDescription';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Conversation/GroupDescription', module);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n,
title: text('title', overrideProps.title || 'Sample Title'),
text: text('text', overrideProps.text || 'Default group description'),
});
story.add('Default', () => <GroupDescription {...createProps()} />);
story.add('Long', () => (
<GroupDescription
{...createProps({
text:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas sed vehicula urna. Ut rhoncus, justo a vestibulum elementum, libero ligula molestie massa, et volutpat nibh ipsum sit amet enim. Vestibulum ac mi enim. Nulla fringilla justo justo, volutpat semper ex convallis quis. Proin posuere, mi at auctor tincidunt, magna turpis mattis nibh, ullamcorper vehicula lectus mauris in mauris. Nullam blandit sapien tortor, quis vehicula quam molestie nec. Nam sagittis dolor in eros dapibus scelerisque. Proin vitae ex sed magna lobortis tincidunt. Aenean dictum laoreet dolor, at suscipit ligula fermentum ac. Nam condimentum turpis quis sollicitudin rhoncus.',
})}
/>
));

View file

@ -0,0 +1,61 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useRef, useState } from 'react';
import { Modal } from '../Modal';
import { LocalizerType } from '../../types/Util';
export type PropsType = {
i18n: LocalizerType;
title: string;
text: string;
};
export const GroupDescription = ({
i18n,
title,
text,
}: PropsType): JSX.Element => {
const textRef = useRef<HTMLDivElement | null>(null);
const [hasReadMore, setHasReadMore] = useState(false);
const [showFullDescription, setShowFullDescription] = useState(false);
useEffect(() => {
if (!textRef || !textRef.current) {
return;
}
setHasReadMore(textRef.current.scrollHeight > textRef.current.clientHeight);
}, [setHasReadMore, textRef]);
return (
<>
{showFullDescription && (
<Modal
hasXButton
i18n={i18n}
onClose={() => setShowFullDescription(false)}
title={title}
>
{text}
</Modal>
)}
<div className="GroupDescription__text" ref={textRef}>
{text}
</div>
{hasReadMore && (
<button
className="GroupDescription__read-more"
onClick={ev => {
ev.preventDefault();
ev.stopPropagation();
setShowFullDescription(true);
}}
type="button"
>
{i18n('GroupDescription__read-more')}
</button>
)}
</>
);
};

View file

@ -1324,4 +1324,65 @@ storiesOf('Components/Conversation/GroupV2Change', module)
})}
</>
);
})
.add('Description (Remove)', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
removed: true,
type: 'description',
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
removed: true,
type: 'description',
},
],
})}
{renderChange({
details: [
{
removed: true,
type: 'description',
},
],
})}
</>
);
})
.add('Description (Change)', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'description',
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
type: 'description',
},
],
})}
{renderChange({
details: [
{
type: 'description',
},
],
})}
</>
);
});

View file

@ -24,6 +24,7 @@ const conversation: ConversationType = getDefaultConversation({
id: '',
lastUpdated: 0,
title: 'Some Conversation',
groupDescription: 'Hello World!',
type: 'group',
sharedGroupNames: [],
conversationColor: 'ultramarine' as const,

View file

@ -67,6 +67,7 @@ export type StateProps = {
updateGroupAttributes: (
_: Readonly<{
avatar?: undefined | ArrayBuffer;
description?: string;
title?: string;
}>
) => Promise<void>;
@ -145,10 +146,12 @@ export const ConversationDetails: React.ComponentType<Props> = ({
modalNode = (
<EditConversationAttributesModal
avatarPath={conversation.avatarPath}
groupDescription={conversation.groupDescription}
i18n={i18n}
makeRequest={async (
options: Readonly<{
avatar?: undefined | ArrayBuffer;
description?: string;
title?: string;
}>
) => {

View file

@ -1,12 +1,13 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { ReactNode } from 'react';
import { Avatar } from '../../Avatar';
import { Emojify } from '../Emojify';
import { LocalizerType } from '../../../types/Util';
import { ConversationType } from '../../../state/ducks/conversations';
import { GroupDescription } from '../GroupDescription';
import { GroupV2Membership } from './ConversationDetailsMembershipList';
import { bemGenerator } from './util';
@ -27,6 +28,23 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
memberships,
startEditing,
}) => {
let subtitle: ReactNode;
if (conversation.groupDescription) {
subtitle = (
<GroupDescription
i18n={i18n}
text={conversation.groupDescription}
title={conversation.title}
/>
);
} else if (canEdit) {
subtitle = i18n('ConversationDetailsHeader--add-group-description');
} else {
subtitle = i18n('ConversationDetailsHeader--members', [
memberships.length.toString(),
]);
}
const contents = (
<>
<Avatar
@ -40,11 +58,7 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
<div className={bem('title')}>
<Emojify text={conversation.title} />
</div>
<div className={bem('subtitle')}>
{i18n('ConversationDetailsHeader--members', [
memberships.length.toString(),
])}
</div>
<div className={bem('subtitle')}>{subtitle}</div>
</div>
</>
);
@ -53,7 +67,11 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
return (
<button
type="button"
onClick={startEditing}
onClick={ev => {
ev.preventDefault();
ev.stopPropagation();
startEditing();
}}
className={bem('root', 'editable')}
>
{contents}

View file

@ -11,10 +11,11 @@ import React, {
import { noop } from 'lodash';
import { LocalizerType } from '../../../types/Util';
import { ModalHost } from '../../ModalHost';
import { Modal } from '../../Modal';
import { AvatarInput, AvatarInputVariant } from '../../AvatarInput';
import { Button, ButtonVariant } from '../../Button';
import { Spinner } from '../../Spinner';
import { GroupDescriptionInput } from '../../GroupDescriptionInput';
import { GroupTitleInput } from '../../GroupTitleInput';
import * as log from '../../../logging/log';
import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer';
@ -24,10 +25,12 @@ const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
type PropsType = {
avatarPath?: string;
groupDescription?: string;
i18n: LocalizerType;
makeRequest: (
_: Readonly<{
avatar?: undefined | ArrayBuffer;
description?: string;
title?: undefined | string;
}>
) => void;
@ -38,6 +41,7 @@ type PropsType = {
export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
avatarPath: externalAvatarPath,
groupDescription: externalGroupDescription = '',
i18n,
makeRequest,
onClose,
@ -51,9 +55,13 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
externalAvatarPath ? TEMPORARY_AVATAR_VALUE : undefined
);
const [rawTitle, setRawTitle] = useState(externalTitle);
const [rawGroupDescription, setRawGroupDescription] = useState(
externalGroupDescription
);
const [hasAvatarChanged, setHasAvatarChanged] = useState(false);
const trimmedTitle = rawTitle.trim();
const trimmedDescription = rawGroupDescription.trim();
useEffect(() => {
const startingAvatarPath = startingAvatarPathRef.current;
@ -88,12 +96,17 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
startingAvatarPathRef.current !== externalAvatarPath ||
startingTitleRef.current !== externalTitle;
const hasTitleChanged = trimmedTitle !== externalTitle.trim();
const hasGroupDescriptionChanged =
externalGroupDescription.trim() !== trimmedDescription;
const isRequestActive = requestState === RequestState.Active;
const canSubmit =
!isRequestActive &&
(hasChangedExternally || hasTitleChanged || hasAvatarChanged) &&
(hasChangedExternally ||
hasTitleChanged ||
hasAvatarChanged ||
hasGroupDescriptionChanged) &&
trimmedTitle.length > 0;
const onSubmit: FormEventHandler<HTMLFormElement> = event => {
@ -101,6 +114,7 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
const request: {
avatar?: undefined | ArrayBuffer;
description?: string;
title?: string;
} = {};
if (hasAvatarChanged) {
@ -109,29 +123,23 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
if (hasTitleChanged) {
request.title = trimmedTitle;
}
if (hasGroupDescriptionChanged) {
request.description = trimmedDescription;
}
makeRequest(request);
};
return (
<ModalHost onClose={onClose}>
<Modal
hasXButton
i18n={i18n}
onClose={onClose}
title={i18n('updateGroupAttributes__title')}
>
<form
onSubmit={onSubmit}
className="module-EditConversationAttributesModal"
>
<button
aria-label={i18n('close')}
className="module-EditConversationAttributesModal__close-button"
disabled={isRequestActive}
type="button"
onClick={() => {
onClose();
}}
/>
<h1 className="module-EditConversationAttributesModal__header">
{i18n('updateGroupAttributes__title')}
</h1>
<AvatarInput
contextMenuId="edit conversation attributes avatar input"
disabled={isRequestActive}
@ -151,13 +159,24 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
value={rawTitle}
/>
<GroupDescriptionInput
disabled={isRequestActive}
i18n={i18n}
onChangeValue={setRawGroupDescription}
value={rawGroupDescription}
/>
<div className="module-EditConversationAttributesModal__description-warning">
{i18n('EditConversationAttributesModal__description-warning')}
</div>
{requestState === RequestState.InactiveWithError && (
<div className="module-EditConversationAttributesModal__error-message">
{i18n('updateGroupAttributes__error-message')}
</div>
)}
<div className="module-EditConversationAttributesModal__button-container">
<Modal.ButtonFooter>
<Button
disabled={isRequestActive}
onClick={onClose}
@ -177,9 +196,9 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
i18n('save')
)}
</Button>
</div>
</Modal.ButtonFooter>
</form>
</ModalHost>
</Modal>
);
};

View file

@ -44,24 +44,6 @@ export const GroupV2Permissions: React.ComponentType<PropsType> = ({
return (
<PanelSection>
<PanelRow
label={i18n('ConversationDetails--group-info-label')}
info={i18n('ConversationDetails--group-info-info')}
right={
<div className="module-conversation-details-select">
<select
onChange={updateAccessControlAttributes}
value={conversation.accessControlAttributes}
>
{accessControlOptions.map(({ name, value }) => (
<option aria-label={name} key={name} value={value}>
{name}
</option>
))}
</select>
</div>
}
/>
<PanelRow
label={i18n('ConversationDetails--add-members-label')}
info={i18n('ConversationDetails--add-members-info')}
@ -80,6 +62,24 @@ export const GroupV2Permissions: React.ComponentType<PropsType> = ({
</div>
}
/>
<PanelRow
label={i18n('ConversationDetails--group-info-label')}
info={i18n('ConversationDetails--group-info-info')}
right={
<div className="module-conversation-details-select">
<select
onChange={updateAccessControlAttributes}
value={conversation.accessControlAttributes}
>
{accessControlOptions.map(({ name, value }) => (
<option aria-label={name} key={name} value={value}>
{name}
</option>
))}
</select>
</div>
}
/>
</PanelSection>
);
};