Group Description: Edit/Receive
This commit is contained in:
parent
e5d365dfc4
commit
9705f464be
29 changed files with 859 additions and 149 deletions
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
17
stylesheets/components/GroupDescription.scss
Normal file
17
stylesheets/components/GroupDescription.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
133
ts/groups.ts
133
ts/groups.ts
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
1
ts/model-types.d.ts
vendored
|
@ -292,6 +292,7 @@ export type ConversationAttributesType = {
|
|||
path: string;
|
||||
hash?: string;
|
||||
} | null;
|
||||
description?: string;
|
||||
expireTimer?: number;
|
||||
membersV2?: Array<GroupV2MemberType>;
|
||||
pendingMembersV2?: Array<GroupV2PendingMemberType>;
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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
14
ts/textsecure.d.ts
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Reference in a new issue