Group Description: Edit/Receive
This commit is contained in:
parent
e5d365dfc4
commit
9705f464be
29 changed files with 859 additions and 149 deletions
151
ts/components/GroupDescriptionInput.tsx
Normal file
151
ts/components/GroupDescriptionInput.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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)',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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({
|
||||
|
|
32
ts/components/conversation/GroupDescription.stories.tsx
Normal file
32
ts/components/conversation/GroupDescription.stories.tsx
Normal 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.',
|
||||
})}
|
||||
/>
|
||||
));
|
61
ts/components/conversation/GroupDescription.tsx
Normal file
61
ts/components/conversation/GroupDescription.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -24,6 +24,7 @@ const conversation: ConversationType = getDefaultConversation({
|
|||
id: '',
|
||||
lastUpdated: 0,
|
||||
title: 'Some Conversation',
|
||||
groupDescription: 'Hello World!',
|
||||
type: 'group',
|
||||
sharedGroupNames: [],
|
||||
conversationColor: 'ultramarine' as const,
|
||||
|
|
|
@ -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;
|
||||
}>
|
||||
) => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue