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

@ -2037,6 +2037,10 @@
"message": "Group name (required)",
"description": "The placeholder for the group name placeholder"
},
"setGroupMetadata__group-description-placeholder": {
"message": "Description",
"description": "The placeholder for the group description"
},
"setGroupMetadata__create-group": {
"message": "Create",
"description": "The 'create group' button text in the 'set group metadata' left pane screen"
@ -2050,7 +2054,7 @@
"description": "Shown in the modal when we can't create a group"
},
"updateGroupAttributes__title": {
"message": "Edit group name and photo",
"message": "Edit group",
"description": "Shown in the modal when we want to update a group"
},
"updateGroupAttributes__error-message": {
@ -4631,6 +4635,43 @@
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--description--remove--you": {
"message": "You removed the group description.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--description--remove--other": {
"message": "$memberName$ removed the group description.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Alice"
}
}
},
"GroupV2--description--remove--unknown": {
"message": "The group description was removed.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--description--change--you": {
"message": "You changed the group description.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--description--change--other": {
"message": "$memberName$ changed the group description.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Alice"
}
}
},
"GroupV2--description--change--unknown": {
"message": "The group description was changed.",
"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).",
@ -4842,7 +4883,7 @@
"description": "This is the label for the 'who can edit the group' panel"
},
"ConversationDetails--group-info-info": {
"message": "Choose who can edit group name, avatar, and disappearing messages timer.",
"message": "Choose who can edit group name, photo, description, and disappearing messages timer.",
"description": "This is the additional info for the 'who can edit the group' panel"
},
"ConversationDetails--add-members-label": {
@ -5530,5 +5571,17 @@
"example": "1 week"
}
}
},
"GroupDescription__read-more": {
"message": "read more",
"description": "Button text when the group description is too long"
},
"EditConversationAttributesModal__description-warning": {
"message": "Group descriptions will be visible to members of this group and people who have been invited.",
"description": "Label text shown when editing group description"
},
"ConversationDetailsHeader--add-group-description": {
"message": "Add group description...",
"description": "Placeholder text in the details header for those that can edit the group description"
}
}

View file

@ -67,6 +67,7 @@ message Group {
repeated MemberPendingProfileKey membersPendingProfileKey = 8;
repeated MemberPendingAdminApproval membersPendingAdminApproval = 9;
bytes inviteLinkPassword = 10;
bytes descriptionBytes = 11;
}
message GroupChange {
@ -148,6 +149,10 @@ message GroupChange {
bytes inviteLinkPassword = 1;
}
message ModifyDescriptionAction {
bytes descriptionBytes = 1;
}
bytes sourceUuid = 1; // Who made the change
uint32 version = 2; // The change version number
@ -163,11 +168,12 @@ message GroupChange {
ModifyDisappearingMessagesTimerAction modifyDisappearingMessagesTimer = 12; // Changed timer
ModifyAttributesAccessControlAction modifyAttributesAccess = 13; // Changed attributes access control
ModifyMembersAccessControlAction modifyMemberAccess = 14; // Changed membership access control
ModifyAddFromInviteLinkAccessControlAction modifyAddFromInviteLinkAccess = 15;
repeated AddMemberPendingAdminApprovalAction addMemberPendingAdminApprovals = 16;
repeated DeleteMemberPendingAdminApprovalAction deleteMemberPendingAdminApprovals = 17;
repeated PromoteMemberPendingAdminApprovalAction promoteMemberPendingAdminApprovals = 18;
ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19;
ModifyAddFromInviteLinkAccessControlAction modifyAddFromInviteLinkAccess = 15; // change epoch = 1
repeated AddMemberPendingAdminApprovalAction addMemberPendingAdminApprovals = 16; // change epoch = 1
repeated DeleteMemberPendingAdminApprovalAction deleteMemberPendingAdminApprovals = 17; // change epoch = 1
repeated PromoteMemberPendingAdminApprovalAction promoteMemberPendingAdminApprovals = 18; // change epoch = 1
ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1
ModifyDescriptionAction modifyDescription = 20; // change epoch = 2
}
bytes actions = 1; // The serialized actions
@ -189,6 +195,7 @@ message GroupAttributeBlob {
string title = 1;
bytes avatar = 2;
uint32 disappearingMessagesDuration = 3;
string descriptionText = 4;
}
}
@ -208,11 +215,12 @@ message GroupInviteLink {
}
message GroupJoinInfo {
bytes publicKey = 1;
bytes title = 2;
string avatar = 3;
uint32 memberCount = 4;
AccessControl.AccessRequired addFromInviteLink = 5;
uint32 version = 6;
bool pendingAdminApproval = 7;
bytes publicKey = 1;
bytes title = 2;
string avatar = 3;
uint32 memberCount = 4;
AccessControl.AccessRequired addFromInviteLink = 5;
uint32 version = 6;
bool pendingAdminApproval = 7;
bytes descriptionBytes = 8;
}

View file

@ -2662,6 +2662,7 @@ button.module-conversation-details__action-button {
@include font-title-1;
align-items: center;
display: flex;
justify-content: center;
padding-bottom: 8px;
padding-top: 12px;
}
@ -2669,6 +2670,7 @@ button.module-conversation-details__action-button {
&__subtitle {
@include font-body-1;
color: $color-gray-60;
justify-content: center;
padding-bottom: 6px;
@include dark-theme {
@ -3651,7 +3653,9 @@ button.module-conversation-details__action-button {
&__with {
@include font-body-2;
margin: 0 auto;
margin-bottom: 16px;
max-width: 500px;
@include light-theme {
color: $color-gray-60;
@ -9862,10 +9866,17 @@ button.module-image__border-overlay:focus {
text-align: center;
}
.module-group-v2-join-dialog__metadata {
color: $color-gray-60;
text-align: center;
}
.module-group-v2-join-dialog__prompt {
margin-top: 40px;
&--approval {
@include font-subtitle;
color: $color-gray-45;
margin-top: 40px;
}
}
.module-group-v2-join-dialog__buttons {
margin-top: 16px;
@ -9883,6 +9894,10 @@ button.module-image__border-overlay:focus {
margin-left: 16px;
}
}
.module-group-v2-join-dialog__description {
color: $color-gray-60;
margin-top: 12px;
}
// Module: Progress Dialog

View file

@ -2,19 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
.module-EditConversationAttributesModal {
@include modal-reset;
&__close-button {
@include modal-close-button;
}
&__header {
@include font-body-1-bold;
margin: 0;
}
.module-AvatarInput {
margin: 40px 0 24px 0;
margin: 24px 0 24px 0;
}
&__error-message {
@ -22,17 +11,9 @@
margin: 16px 0;
}
&__button-container {
display: flex;
justify-content: flex-end;
margin-top: 16px;
flex-grow: 0;
flex-shrink: 0;
.module-Button {
&:not(:first-child) {
margin-left: 12px;
}
}
&__description-warning {
@include font-subtitle;
color: $color-gray-45;
margin: 0 16px;
}
}

View file

@ -0,0 +1,17 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.GroupDescription {
&__text {
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
display: -webkit-box;
overflow: hidden;
}
&__read-more {
@include button-reset();
display: inline-block;
font-weight: bold;
}
}

View file

@ -1,13 +1,13 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-GroupTitleInput {
margin: 16px;
.module-GroupInput {
@include font-body-1;
padding: 8px 12px;
border-radius: 6px;
border-width: 2px;
border-style: solid;
width: 100%;
@include light-theme {
background: $color-white;
@ -43,4 +43,31 @@
border-color: $color-ultramarine-light;
}
}
&__description {
resize: none;
&--container {
margin-bottom: 12px;
margin-top: 0;
}
&--remaining {
@include font-subtitle;
bottom: 0;
color: $color-gray-45;
margin: 12px;
position: absolute;
right: 0;
}
&--large {
height: 280px;
}
}
&--container {
position: relative;
margin: 16px;
}
}

View file

@ -45,8 +45,9 @@
@import './components/EditConversationAttributesModal.scss';
@import './components/ForwardMessageModal.scss';
@import './components/GradientDial.scss';
@import './components/GroupDescription.scss';
@import './components/GroupDialog.scss';
@import './components/GroupTitleInput.scss';
@import './components/GroupInput.scss';
@import './components/MessageAudio.scss';
@import './components/Modal.scss';
@import './components/SafetyNumberChangeDialog.scss';

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

View file

@ -828,6 +828,29 @@ export function renderChangeDetail(
}
return renderString('GroupV2--group-link-remove--unknown', i18n);
}
if (detail.type === 'description') {
if (detail.removed) {
if (fromYou) {
return renderString('GroupV2--description--remove--you', i18n);
}
if (from) {
return renderString('GroupV2--description--remove--other', i18n, [
renderContact(from),
]);
}
return renderString('GroupV2--description--remove--unknown', i18n);
}
if (fromYou) {
return renderString('GroupV2--description--change--you', i18n);
}
if (from) {
return renderString('GroupV2--description--change--other', i18n, [
renderContact(from),
]);
}
return renderString('GroupV2--description--change--unknown', i18n);
}
throw missingCaseError(detail);
}

View file

@ -178,6 +178,10 @@ export type GroupV2AdminApprovalRemoveOneChangeType = {
conversationId: string;
inviter?: string;
};
export type GroupV2DescriptionChangeType = {
type: 'description';
removed?: boolean;
};
export type GroupV2ChangeDetailType =
| GroupV2AccessAttributesChangeType
@ -187,9 +191,10 @@ export type GroupV2ChangeDetailType =
| GroupV2AdminApprovalAddOneChangeType
| GroupV2AdminApprovalRemoveOneChangeType
| GroupV2AvatarChangeType
| GroupV2DescriptionChangeType
| GroupV2GroupLinkAddChangeType
| GroupV2GroupLinkResetChangeType
| GroupV2GroupLinkRemoveChangeType
| GroupV2GroupLinkResetChangeType
| GroupV2MemberAddChangeType
| GroupV2MemberAddFromAdminApprovalChangeType
| GroupV2MemberAddFromInviteChangeType
@ -251,12 +256,13 @@ type UploadedAvatarType = {
export const MASTER_KEY_LENGTH = 32;
const GROUP_TITLE_MAX_ENCRYPTED_BYTES = 1024;
const GROUP_DESC_MAX_ENCRYPTED_BYTES = 8192;
export const ID_V1_LENGTH = 16;
export const ID_LENGTH = 32;
const TEMPORAL_AUTH_REJECTED_CODE = 401;
const GROUP_ACCESS_DENIED_CODE = 403;
const GROUP_NONEXISTENT_CODE = 404;
const SUPPORTED_CHANGE_EPOCH = 1;
const SUPPORTED_CHANGE_EPOCH = 2;
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
@ -415,6 +421,25 @@ function buildGroupTitleBuffer(
return result;
}
function buildGroupDescriptionBuffer(
clientZkGroupCipher: ClientZkGroupCipher,
description: string
): ArrayBuffer {
const attrsBlob = new window.textsecure.protobuf.GroupAttributeBlob();
attrsBlob.descriptionText = description;
const attrsBlobPlaintext = attrsBlob.toArrayBuffer();
const result = encryptGroupBlob(clientZkGroupCipher, attrsBlobPlaintext);
if (result.byteLength > GROUP_DESC_MAX_ENCRYPTED_BYTES) {
throw new Error(
'buildGroupDescriptionBuffer: encrypted group title is too long'
);
}
return result;
}
function buildGroupProto(
attributes: Pick<
ConversationAttributesType,
@ -716,6 +741,7 @@ export async function buildUpdateAttributesChange(
>,
attributes: Readonly<{
avatar?: undefined | ArrayBuffer;
description?: string;
title?: string;
}>
): Promise<undefined | GroupChangeClass.Actions> {
@ -774,6 +800,17 @@ export async function buildUpdateAttributesChange(
);
}
const { description } = attributes;
if (typeof description === 'string') {
hasChangedSomething = true;
actions.modifyDescription = new window.textsecure.protobuf.GroupChange.Actions.ModifyDescriptionAction();
actions.modifyDescription.descriptionBytes = buildGroupDescriptionBuffer(
clientZkGroupCipher,
description
);
}
if (!hasChangedSomething) {
// This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning
// will be logged.
@ -3612,6 +3649,14 @@ function extractDiffs({
});
}
// description
if (old.description !== current.description) {
details.push({
type: 'description',
removed: !current.description,
});
}
// No disappearing message timer check here - see below
// membersV2
@ -4396,6 +4441,19 @@ async function applyGroupChange({
}
}
// modifyDescription?: GroupChangeClass.Actions.ModifyDescriptionAction;
if (actions.modifyDescription) {
const { descriptionBytes } = actions.modifyDescription;
if (descriptionBytes && descriptionBytes.content === 'descriptionText') {
result.description = descriptionBytes.descriptionText;
} else {
window.log.warn(
`applyGroupChange/${logId}: Clearing group description due to missing data.`
);
result.description = undefined;
}
}
if (ourConversationId) {
result.left = !members[ourConversationId];
}
@ -4669,6 +4727,14 @@ async function applyGroupState({
result.groupInviteLinkPassword = undefined;
}
// descriptionBytes
const { descriptionBytes } = groupState;
if (descriptionBytes && descriptionBytes.content === 'descriptionText') {
result.description = descriptionBytes.descriptionText;
} else {
result.description = undefined;
}
return {
newAttributes: result,
newProfileKeys,
@ -5219,6 +5285,29 @@ function decryptGroupChange(
actions.modifyInviteLinkPassword = undefined;
}
// modifyDescription?: GroupChangeClass.Actions.ModifyDescriptionAction;
if (
actions.modifyDescription &&
!isByteBufferEmpty(actions.modifyDescription.descriptionBytes)
) {
try {
actions.modifyDescription.descriptionBytes = window.textsecure.protobuf.GroupAttributeBlob.decode(
decryptGroupBlob(
clientZkGroupCipher,
actions.modifyDescription.descriptionBytes.toArrayBuffer()
)
);
} catch (error) {
window.log.warn(
`decryptGroupChange/${logId}: Unable to decrypt modifyDescription.descriptionBytes`,
error && error.stack ? error.stack : error
);
actions.modifyDescription.descriptionBytes = undefined;
}
} else if (actions.modifyDescription) {
actions.modifyDescription.descriptionBytes = undefined;
}
return actions;
}
@ -5240,6 +5329,26 @@ export function decryptGroupTitle(
return undefined;
}
export function decryptGroupDescription(
description: ProtoBinaryType,
secretParams: string
): string | undefined {
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
if (isByteBufferEmpty(description)) {
return undefined;
}
const blob = window.textsecure.protobuf.GroupAttributeBlob.decode(
decryptGroupBlob(clientZkGroupCipher, description.toArrayBuffer())
);
if (blob && blob.content === 'descriptionText') {
return blob.descriptionText;
}
return undefined;
}
function decryptGroupState(
groupState: GroupClass,
groupSecretParams: string,
@ -5349,6 +5458,26 @@ function decryptGroupState(
groupState.inviteLinkPassword = undefined;
}
// descriptionBytes
if (!isByteBufferEmpty(groupState.descriptionBytes)) {
try {
groupState.descriptionBytes = window.textsecure.protobuf.GroupAttributeBlob.decode(
decryptGroupBlob(
clientZkGroupCipher,
groupState.descriptionBytes.toArrayBuffer()
)
);
} catch (error) {
window.log.warn(
`decryptGroupState/${logId}: Unable to decrypt descriptionBytes. Clearing it.`,
error && error.stack ? error.stack : error
);
groupState.descriptionBytes = undefined;
}
} else {
groupState.descriptionBytes = undefined;
}
return groupState;
}

View file

@ -3,6 +3,7 @@
import {
applyNewAvatar,
decryptGroupDescription,
decryptGroupTitle,
deriveGroupFields,
getPreJoinGroupInfo,
@ -123,6 +124,10 @@ export async function joinViaLink(hash: string): Promise<void> {
const title =
decryptGroupTitle(result.title, secretParams) ||
window.i18n('unknownGroup');
const groupDescription = decryptGroupDescription(
result.descriptionBytes,
secretParams
);
if (
approvalRequired &&
@ -162,6 +167,7 @@ export async function joinViaLink(hash: string): Promise<void> {
return {
approvalRequired,
avatar,
groupDescription,
memberCount,
title,
};

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

@ -292,6 +292,7 @@ export type ConversationAttributesType = {
path: string;
hash?: string;
} | null;
description?: string;
expireTimer?: number;
membersV2?: Array<GroupV2MemberType>;
pendingMembersV2?: Array<GroupV2PendingMemberType>;

View file

@ -1476,6 +1476,7 @@ export class ConversationModel extends window.Backbone
draftPreview,
draftText,
firstName: this.get('profileName')!,
groupDescription: this.get('description'),
groupVersion,
groupId: this.get('groupId'),
groupLink: this.getGroupLink(),
@ -1912,6 +1913,7 @@ export class ConversationModel extends window.Backbone
async updateGroupAttributesV2(
attributes: Readonly<{
avatar?: undefined | ArrayBuffer;
description?: string;
title?: string;
}>
): Promise<void> {

View file

@ -140,6 +140,7 @@ export type ConversationType = {
draftPreview?: string;
sharedGroupNames: Array<string>;
groupDescription?: string;
groupVersion?: 1 | 2;
groupId?: string;
groupLink?: string;
@ -239,6 +240,7 @@ export type PreJoinConversationType = {
loading?: boolean;
url?: string;
};
groupDescription?: string;
memberCount: number;
title: string;
approvalRequired: boolean;

14
ts/textsecure.d.ts vendored
View file

@ -280,6 +280,7 @@ export declare class GroupClass {
membersPendingProfileKey?: Array<MemberPendingProfileKeyClass>;
membersPendingAdminApproval?: Array<MemberPendingAdminApprovalClass>;
inviteLinkPassword?: ProtoBinaryType;
descriptionBytes?: ProtoBinaryType;
}
export declare class GroupChangeClass {
@ -322,6 +323,7 @@ export declare namespace GroupChangeClass {
deleteMemberPendingAdminApprovals?: Array<GroupChangeClass.Actions.DeleteMemberPendingAdminApprovalAction>;
promoteMemberPendingAdminApprovals?: Array<GroupChangeClass.Actions.PromoteMemberPendingAdminApprovalAction>;
modifyInviteLinkPassword?: GroupChangeClass.Actions.ModifyInviteLinkPasswordAction;
modifyDescription?: GroupChangeClass.Actions.ModifyDescriptionAction;
}
}
@ -405,6 +407,10 @@ export declare namespace GroupChangeClass.Actions {
class ModifyInviteLinkPasswordAction {
inviteLinkPassword?: ProtoBinaryType;
}
class ModifyDescriptionAction {
descriptionBytes?: ProtoBinaryType;
}
}
export declare class GroupChangesClass {
@ -434,10 +440,15 @@ export declare class GroupAttributeBlobClass {
title?: string;
avatar?: ProtoBinaryType;
disappearingMessagesDuration?: number;
descriptionText?: string;
// Note: this isn't part of the proto, but our protobuf library tells us which
// field has been set with this prop.
content: 'title' | 'avatar' | 'disappearingMessagesDuration';
content:
| 'title'
| 'avatar'
| 'disappearingMessagesDuration'
| 'descriptionText';
}
export declare class GroupExternalCredentialClass {
@ -483,6 +494,7 @@ export declare class GroupJoinInfoClass {
addFromInviteLink?: AccessControlClass.AccessRequired;
version?: number;
pendingAdminApproval?: boolean;
descriptionBytes?: ProtoBinaryType;
}
// Previous protos

View file

@ -13767,6 +13767,27 @@
"updated": "2020-11-17T23:29:38.698Z",
"reasonDetail": "Doesn't touch the DOM."
},
{
"rule": "React-useRef",
"path": "ts/components/GroupDescriptionInput.js",
"line": " const innerRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-29T02:15:39.186Z"
},
{
"rule": "React-useRef",
"path": "ts/components/GroupDescriptionInput.js",
"line": " const valueOnKeydownRef = react_1.useRef(value);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-29T02:15:39.186Z"
},
{
"rule": "React-useRef",
"path": "ts/components/GroupDescriptionInput.js",
"line": " const selectionStartOnKeydownRef = react_1.useRef(value.length);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-29T02:15:39.186Z"
},
{
"rule": "React-useRef",
"path": "ts/components/GroupTitleInput.js",
@ -13951,6 +13972,13 @@
"updated": "2021-04-17T01:47:31.419Z",
"reasonDetail": "Used for managing playback of GIF video"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/GroupDescription.js",
"line": " const textRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-29T02:15:39.186Z"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/InlineNotificationWrapper.js",