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)",
|
"message": "Group name (required)",
|
||||||
"description": "The placeholder for the group name placeholder"
|
"description": "The placeholder for the group name placeholder"
|
||||||
},
|
},
|
||||||
|
"setGroupMetadata__group-description-placeholder": {
|
||||||
|
"message": "Description",
|
||||||
|
"description": "The placeholder for the group description"
|
||||||
|
},
|
||||||
"setGroupMetadata__create-group": {
|
"setGroupMetadata__create-group": {
|
||||||
"message": "Create",
|
"message": "Create",
|
||||||
"description": "The 'create group' button text in the 'set group metadata' left pane screen"
|
"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"
|
"description": "Shown in the modal when we can't create a group"
|
||||||
},
|
},
|
||||||
"updateGroupAttributes__title": {
|
"updateGroupAttributes__title": {
|
||||||
"message": "Edit group name and photo",
|
"message": "Edit group",
|
||||||
"description": "Shown in the modal when we want to update a group"
|
"description": "Shown in the modal when we want to update a group"
|
||||||
},
|
},
|
||||||
"updateGroupAttributes__error-message": {
|
"updateGroupAttributes__error-message": {
|
||||||
|
@ -4631,6 +4635,43 @@
|
||||||
"description": "Shown in timeline or conversation preview when v2 group changes"
|
"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": {
|
"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$",
|
"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).",
|
"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"
|
"description": "This is the label for the 'who can edit the group' panel"
|
||||||
},
|
},
|
||||||
"ConversationDetails--group-info-info": {
|
"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"
|
"description": "This is the additional info for the 'who can edit the group' panel"
|
||||||
},
|
},
|
||||||
"ConversationDetails--add-members-label": {
|
"ConversationDetails--add-members-label": {
|
||||||
|
@ -5530,5 +5571,17 @@
|
||||||
"example": "1 week"
|
"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 MemberPendingProfileKey membersPendingProfileKey = 8;
|
||||||
repeated MemberPendingAdminApproval membersPendingAdminApproval = 9;
|
repeated MemberPendingAdminApproval membersPendingAdminApproval = 9;
|
||||||
bytes inviteLinkPassword = 10;
|
bytes inviteLinkPassword = 10;
|
||||||
|
bytes descriptionBytes = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GroupChange {
|
message GroupChange {
|
||||||
|
@ -148,6 +149,10 @@ message GroupChange {
|
||||||
bytes inviteLinkPassword = 1;
|
bytes inviteLinkPassword = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ModifyDescriptionAction {
|
||||||
|
bytes descriptionBytes = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
bytes sourceUuid = 1; // Who made the change
|
bytes sourceUuid = 1; // Who made the change
|
||||||
uint32 version = 2; // The change version number
|
uint32 version = 2; // The change version number
|
||||||
|
@ -163,11 +168,12 @@ message GroupChange {
|
||||||
ModifyDisappearingMessagesTimerAction modifyDisappearingMessagesTimer = 12; // Changed timer
|
ModifyDisappearingMessagesTimerAction modifyDisappearingMessagesTimer = 12; // Changed timer
|
||||||
ModifyAttributesAccessControlAction modifyAttributesAccess = 13; // Changed attributes access control
|
ModifyAttributesAccessControlAction modifyAttributesAccess = 13; // Changed attributes access control
|
||||||
ModifyMembersAccessControlAction modifyMemberAccess = 14; // Changed membership access control
|
ModifyMembersAccessControlAction modifyMemberAccess = 14; // Changed membership access control
|
||||||
ModifyAddFromInviteLinkAccessControlAction modifyAddFromInviteLinkAccess = 15;
|
ModifyAddFromInviteLinkAccessControlAction modifyAddFromInviteLinkAccess = 15; // change epoch = 1
|
||||||
repeated AddMemberPendingAdminApprovalAction addMemberPendingAdminApprovals = 16;
|
repeated AddMemberPendingAdminApprovalAction addMemberPendingAdminApprovals = 16; // change epoch = 1
|
||||||
repeated DeleteMemberPendingAdminApprovalAction deleteMemberPendingAdminApprovals = 17;
|
repeated DeleteMemberPendingAdminApprovalAction deleteMemberPendingAdminApprovals = 17; // change epoch = 1
|
||||||
repeated PromoteMemberPendingAdminApprovalAction promoteMemberPendingAdminApprovals = 18;
|
repeated PromoteMemberPendingAdminApprovalAction promoteMemberPendingAdminApprovals = 18; // change epoch = 1
|
||||||
ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19;
|
ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1
|
||||||
|
ModifyDescriptionAction modifyDescription = 20; // change epoch = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
bytes actions = 1; // The serialized actions
|
bytes actions = 1; // The serialized actions
|
||||||
|
@ -189,6 +195,7 @@ message GroupAttributeBlob {
|
||||||
string title = 1;
|
string title = 1;
|
||||||
bytes avatar = 2;
|
bytes avatar = 2;
|
||||||
uint32 disappearingMessagesDuration = 3;
|
uint32 disappearingMessagesDuration = 3;
|
||||||
|
string descriptionText = 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -208,11 +215,12 @@ message GroupInviteLink {
|
||||||
}
|
}
|
||||||
|
|
||||||
message GroupJoinInfo {
|
message GroupJoinInfo {
|
||||||
bytes publicKey = 1;
|
bytes publicKey = 1;
|
||||||
bytes title = 2;
|
bytes title = 2;
|
||||||
string avatar = 3;
|
string avatar = 3;
|
||||||
uint32 memberCount = 4;
|
uint32 memberCount = 4;
|
||||||
AccessControl.AccessRequired addFromInviteLink = 5;
|
AccessControl.AccessRequired addFromInviteLink = 5;
|
||||||
uint32 version = 6;
|
uint32 version = 6;
|
||||||
bool pendingAdminApproval = 7;
|
bool pendingAdminApproval = 7;
|
||||||
|
bytes descriptionBytes = 8;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2662,6 +2662,7 @@ button.module-conversation-details__action-button {
|
||||||
@include font-title-1;
|
@include font-title-1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
@ -2669,6 +2670,7 @@ button.module-conversation-details__action-button {
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
@include font-body-1;
|
@include font-body-1;
|
||||||
color: $color-gray-60;
|
color: $color-gray-60;
|
||||||
|
justify-content: center;
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
|
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
|
@ -3651,7 +3653,9 @@ button.module-conversation-details__action-button {
|
||||||
|
|
||||||
&__with {
|
&__with {
|
||||||
@include font-body-2;
|
@include font-body-2;
|
||||||
|
margin: 0 auto;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
max-width: 500px;
|
||||||
|
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
color: $color-gray-60;
|
color: $color-gray-60;
|
||||||
|
@ -9862,10 +9866,17 @@ button.module-image__border-overlay:focus {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.module-group-v2-join-dialog__metadata {
|
.module-group-v2-join-dialog__metadata {
|
||||||
|
color: $color-gray-60;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.module-group-v2-join-dialog__prompt {
|
.module-group-v2-join-dialog__prompt {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
|
|
||||||
|
&--approval {
|
||||||
|
@include font-subtitle;
|
||||||
|
color: $color-gray-45;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.module-group-v2-join-dialog__buttons {
|
.module-group-v2-join-dialog__buttons {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
@ -9883,6 +9894,10 @@ button.module-image__border-overlay:focus {
|
||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.module-group-v2-join-dialog__description {
|
||||||
|
color: $color-gray-60;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
// Module: Progress Dialog
|
// Module: Progress Dialog
|
||||||
|
|
||||||
|
|
|
@ -2,19 +2,8 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
.module-EditConversationAttributesModal {
|
.module-EditConversationAttributesModal {
|
||||||
@include modal-reset;
|
|
||||||
|
|
||||||
&__close-button {
|
|
||||||
@include modal-close-button;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
@include font-body-1-bold;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-AvatarInput {
|
.module-AvatarInput {
|
||||||
margin: 40px 0 24px 0;
|
margin: 24px 0 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__error-message {
|
&__error-message {
|
||||||
|
@ -22,17 +11,9 @@
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__button-container {
|
&__description-warning {
|
||||||
display: flex;
|
@include font-subtitle;
|
||||||
justify-content: flex-end;
|
color: $color-gray-45;
|
||||||
margin-top: 16px;
|
margin: 0 16px;
|
||||||
flex-grow: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.module-Button {
|
|
||||||
&:not(:first-child) {
|
|
||||||
margin-left: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
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
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
.module-GroupTitleInput {
|
.module-GroupInput {
|
||||||
margin: 16px;
|
|
||||||
@include font-body-1;
|
@include font-body-1;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
background: $color-white;
|
background: $color-white;
|
||||||
|
@ -43,4 +43,31 @@
|
||||||
border-color: $color-ultramarine-light;
|
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/EditConversationAttributesModal.scss';
|
||||||
@import './components/ForwardMessageModal.scss';
|
@import './components/ForwardMessageModal.scss';
|
||||||
@import './components/GradientDial.scss';
|
@import './components/GradientDial.scss';
|
||||||
|
@import './components/GroupDescription.scss';
|
||||||
@import './components/GroupDialog.scss';
|
@import './components/GroupDialog.scss';
|
||||||
@import './components/GroupTitleInput.scss';
|
@import './components/GroupInput.scss';
|
||||||
@import './components/MessageAudio.scss';
|
@import './components/MessageAudio.scss';
|
||||||
@import './components/Modal.scss';
|
@import './components/Modal.scss';
|
||||||
@import './components/SafetyNumberChangeDialog.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);
|
const selectionStartOnKeydownRef = useRef<number>(value.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<div className="module-GroupInput--container">
|
||||||
disabled={disabled}
|
<input
|
||||||
className="module-GroupTitleInput"
|
disabled={disabled}
|
||||||
onKeyDown={() => {
|
className="module-GroupInput"
|
||||||
const inputEl = innerRef.current;
|
onKeyDown={() => {
|
||||||
if (!inputEl) {
|
const inputEl = innerRef.current;
|
||||||
return;
|
if (!inputEl) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
valueOnKeydownRef.current = inputEl.value;
|
valueOnKeydownRef.current = inputEl.value;
|
||||||
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
|
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
|
||||||
}}
|
}}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
const inputEl = innerRef.current;
|
const inputEl = innerRef.current;
|
||||||
if (!inputEl) {
|
if (!inputEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newValue = inputEl.value;
|
const newValue = inputEl.value;
|
||||||
if (grapheme.count(newValue) <= MAX_GRAPHEME_COUNT) {
|
if (grapheme.count(newValue) <= MAX_GRAPHEME_COUNT) {
|
||||||
onChangeValue(newValue);
|
onChangeValue(newValue);
|
||||||
} else {
|
} else {
|
||||||
inputEl.value = valueOnKeydownRef.current;
|
inputEl.value = valueOnKeydownRef.current;
|
||||||
inputEl.selectionStart = selectionStartOnKeydownRef.current;
|
inputEl.selectionStart = selectionStartOnKeydownRef.current;
|
||||||
inputEl.selectionEnd = selectionStartOnKeydownRef.current;
|
inputEl.selectionEnd = selectionStartOnKeydownRef.current;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPaste={(event: ClipboardEvent<HTMLInputElement>) => {
|
onPaste={(event: ClipboardEvent<HTMLInputElement>) => {
|
||||||
const inputEl = innerRef.current;
|
const inputEl = innerRef.current;
|
||||||
if (!inputEl) {
|
if (!inputEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectionStart = inputEl.selectionStart || 0;
|
const selectionStart = inputEl.selectionStart || 0;
|
||||||
const selectionEnd =
|
const selectionEnd =
|
||||||
inputEl.selectionEnd || inputEl.selectionStart || 0;
|
inputEl.selectionEnd || inputEl.selectionStart || 0;
|
||||||
const textBeforeSelection = value.slice(0, selectionStart);
|
const textBeforeSelection = value.slice(0, selectionStart);
|
||||||
const textAfterSelection = value.slice(selectionEnd);
|
const textAfterSelection = value.slice(selectionEnd);
|
||||||
|
|
||||||
const pastedText = event.clipboardData.getData('Text');
|
const pastedText = event.clipboardData.getData('Text');
|
||||||
|
|
||||||
const newGraphemeCount =
|
const newGraphemeCount =
|
||||||
grapheme.count(textBeforeSelection) +
|
grapheme.count(textBeforeSelection) +
|
||||||
grapheme.count(pastedText) +
|
grapheme.count(pastedText) +
|
||||||
grapheme.count(textAfterSelection);
|
grapheme.count(textAfterSelection);
|
||||||
|
|
||||||
if (newGraphemeCount > MAX_GRAPHEME_COUNT) {
|
if (newGraphemeCount > MAX_GRAPHEME_COUNT) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
|
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
|
||||||
ref={multiRef<HTMLInputElement>(ref, innerRef)}
|
ref={multiRef<HTMLInputElement>(ref, innerRef)}
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -20,6 +20,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
'approvalRequired',
|
'approvalRequired',
|
||||||
overrideProps.approvalRequired || false
|
overrideProps.approvalRequired || false
|
||||||
),
|
),
|
||||||
|
groupDescription: overrideProps.groupDescription,
|
||||||
join: action('join'),
|
join: action('join'),
|
||||||
onClose: action('onClose'),
|
onClose: action('onClose'),
|
||||||
i18n,
|
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 { Avatar, AvatarBlur } from './Avatar';
|
||||||
import { Spinner } from './Spinner';
|
import { Spinner } from './Spinner';
|
||||||
import { Button, ButtonVariant } from './Button';
|
import { Button, ButtonVariant } from './Button';
|
||||||
|
import { GroupDescription } from './conversation/GroupDescription';
|
||||||
|
|
||||||
import { PreJoinConversationType } from '../state/ducks/conversations';
|
import { PreJoinConversationType } from '../state/ducks/conversations';
|
||||||
|
|
||||||
|
@ -35,6 +36,7 @@ export const GroupV2JoinDialog = React.memo((props: PropsType) => {
|
||||||
const {
|
const {
|
||||||
approvalRequired,
|
approvalRequired,
|
||||||
avatar,
|
avatar,
|
||||||
|
groupDescription,
|
||||||
i18n,
|
i18n,
|
||||||
join,
|
join,
|
||||||
memberCount,
|
memberCount,
|
||||||
|
@ -45,9 +47,6 @@ export const GroupV2JoinDialog = React.memo((props: PropsType) => {
|
||||||
const joinString = approvalRequired
|
const joinString = approvalRequired
|
||||||
? i18n('GroupV2--join--request-to-join-button')
|
? i18n('GroupV2--join--request-to-join-button')
|
||||||
: i18n('GroupV2--join--join-button');
|
: i18n('GroupV2--join--join-button');
|
||||||
const promptString = approvalRequired
|
|
||||||
? i18n('GroupV2--join--prompt-with-approval')
|
|
||||||
: i18n('GroupV2--join--prompt');
|
|
||||||
const memberString =
|
const memberString =
|
||||||
memberCount === 1
|
memberCount === 1
|
||||||
? i18n('GroupV2--join--member-count--single')
|
? 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">
|
<div className="module-group-v2-join-dialog__metadata">
|
||||||
{i18n('GroupV2--join--group-metadata', [memberString])}
|
{i18n('GroupV2--join--group-metadata', [memberString])}
|
||||||
</div>
|
</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">
|
<div className="module-group-v2-join-dialog__buttons">
|
||||||
<Button
|
<Button
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
|
|
@ -235,6 +235,26 @@ storiesOf('Components/Conversation/ConversationHero', module)
|
||||||
title={text('title', 'NYC Rock Climbers')}
|
title={text('title', 'NYC Rock Climbers')}
|
||||||
name={text('groupName', 'NYC Rock Climbers')}
|
name={text('groupName', 'NYC Rock Climbers')}
|
||||||
conversationType="group"
|
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}
|
membersCount={0}
|
||||||
sharedGroupNames={[]}
|
sharedGroupNames={[]}
|
||||||
unblurAvatar={action('unblurAvatar')}
|
unblurAvatar={action('unblurAvatar')}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import Measure from 'react-measure';
|
||||||
import { Avatar, AvatarBlur, Props as AvatarProps } from '../Avatar';
|
import { Avatar, AvatarBlur, Props as AvatarProps } from '../Avatar';
|
||||||
import { ContactName } from './ContactName';
|
import { ContactName } from './ContactName';
|
||||||
import { About } from './About';
|
import { About } from './About';
|
||||||
|
import { GroupDescription } from './GroupDescription';
|
||||||
import { SharedGroupNames } from '../SharedGroupNames';
|
import { SharedGroupNames } from '../SharedGroupNames';
|
||||||
import { LocalizerType } from '../../types/Util';
|
import { LocalizerType } from '../../types/Util';
|
||||||
import { ConfirmationDialog } from '../ConfirmationDialog';
|
import { ConfirmationDialog } from '../ConfirmationDialog';
|
||||||
|
@ -16,6 +17,7 @@ import { shouldBlurAvatar } from '../../util/shouldBlurAvatar';
|
||||||
export type Props = {
|
export type Props = {
|
||||||
about?: string;
|
about?: string;
|
||||||
acceptedMessageRequest?: boolean;
|
acceptedMessageRequest?: boolean;
|
||||||
|
groupDescription?: string;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
isMe: boolean;
|
isMe: boolean;
|
||||||
membersCount?: number;
|
membersCount?: number;
|
||||||
|
@ -97,6 +99,7 @@ export const ConversationHero = ({
|
||||||
avatarPath,
|
avatarPath,
|
||||||
color,
|
color,
|
||||||
conversationType,
|
conversationType,
|
||||||
|
groupDescription,
|
||||||
isMe,
|
isMe,
|
||||||
membersCount,
|
membersCount,
|
||||||
sharedGroupNames = [],
|
sharedGroupNames = [],
|
||||||
|
@ -215,13 +218,19 @@ export const ConversationHero = ({
|
||||||
)}
|
)}
|
||||||
{!isMe ? (
|
{!isMe ? (
|
||||||
<div className="module-conversation-hero__with">
|
<div className="module-conversation-hero__with">
|
||||||
{membersCount === 1
|
{groupDescription ? (
|
||||||
? i18n('ConversationHero--members-1')
|
<GroupDescription
|
||||||
: membersCount !== undefined
|
i18n={i18n}
|
||||||
? i18n('ConversationHero--members', [`${membersCount}`])
|
title={title}
|
||||||
: phoneNumberOnly
|
text={groupDescription}
|
||||||
? null
|
/>
|
||||||
: phoneNumber}
|
) : membersCount === 1 ? (
|
||||||
|
i18n('ConversationHero--members-1')
|
||||||
|
) : membersCount !== undefined ? (
|
||||||
|
i18n('ConversationHero--members', [`${membersCount}`])
|
||||||
|
) : phoneNumberOnly ? null : (
|
||||||
|
phoneNumber
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{renderMembershipRow({
|
{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: '',
|
id: '',
|
||||||
lastUpdated: 0,
|
lastUpdated: 0,
|
||||||
title: 'Some Conversation',
|
title: 'Some Conversation',
|
||||||
|
groupDescription: 'Hello World!',
|
||||||
type: 'group',
|
type: 'group',
|
||||||
sharedGroupNames: [],
|
sharedGroupNames: [],
|
||||||
conversationColor: 'ultramarine' as const,
|
conversationColor: 'ultramarine' as const,
|
||||||
|
|
|
@ -67,6 +67,7 @@ export type StateProps = {
|
||||||
updateGroupAttributes: (
|
updateGroupAttributes: (
|
||||||
_: Readonly<{
|
_: Readonly<{
|
||||||
avatar?: undefined | ArrayBuffer;
|
avatar?: undefined | ArrayBuffer;
|
||||||
|
description?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
}>
|
}>
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
@ -145,10 +146,12 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
||||||
modalNode = (
|
modalNode = (
|
||||||
<EditConversationAttributesModal
|
<EditConversationAttributesModal
|
||||||
avatarPath={conversation.avatarPath}
|
avatarPath={conversation.avatarPath}
|
||||||
|
groupDescription={conversation.groupDescription}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
makeRequest={async (
|
makeRequest={async (
|
||||||
options: Readonly<{
|
options: Readonly<{
|
||||||
avatar?: undefined | ArrayBuffer;
|
avatar?: undefined | ArrayBuffer;
|
||||||
|
description?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
|
|
||||||
import { Avatar } from '../../Avatar';
|
import { Avatar } from '../../Avatar';
|
||||||
import { Emojify } from '../Emojify';
|
import { Emojify } from '../Emojify';
|
||||||
import { LocalizerType } from '../../../types/Util';
|
import { LocalizerType } from '../../../types/Util';
|
||||||
import { ConversationType } from '../../../state/ducks/conversations';
|
import { ConversationType } from '../../../state/ducks/conversations';
|
||||||
|
import { GroupDescription } from '../GroupDescription';
|
||||||
import { GroupV2Membership } from './ConversationDetailsMembershipList';
|
import { GroupV2Membership } from './ConversationDetailsMembershipList';
|
||||||
import { bemGenerator } from './util';
|
import { bemGenerator } from './util';
|
||||||
|
|
||||||
|
@ -27,6 +28,23 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
|
||||||
memberships,
|
memberships,
|
||||||
startEditing,
|
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 = (
|
const contents = (
|
||||||
<>
|
<>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
@ -40,11 +58,7 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
|
||||||
<div className={bem('title')}>
|
<div className={bem('title')}>
|
||||||
<Emojify text={conversation.title} />
|
<Emojify text={conversation.title} />
|
||||||
</div>
|
</div>
|
||||||
<div className={bem('subtitle')}>
|
<div className={bem('subtitle')}>{subtitle}</div>
|
||||||
{i18n('ConversationDetailsHeader--members', [
|
|
||||||
memberships.length.toString(),
|
|
||||||
])}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -53,7 +67,11 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startEditing}
|
onClick={ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
startEditing();
|
||||||
|
}}
|
||||||
className={bem('root', 'editable')}
|
className={bem('root', 'editable')}
|
||||||
>
|
>
|
||||||
{contents}
|
{contents}
|
||||||
|
|
|
@ -11,10 +11,11 @@ import React, {
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
import { LocalizerType } from '../../../types/Util';
|
import { LocalizerType } from '../../../types/Util';
|
||||||
import { ModalHost } from '../../ModalHost';
|
import { Modal } from '../../Modal';
|
||||||
import { AvatarInput, AvatarInputVariant } from '../../AvatarInput';
|
import { AvatarInput, AvatarInputVariant } from '../../AvatarInput';
|
||||||
import { Button, ButtonVariant } from '../../Button';
|
import { Button, ButtonVariant } from '../../Button';
|
||||||
import { Spinner } from '../../Spinner';
|
import { Spinner } from '../../Spinner';
|
||||||
|
import { GroupDescriptionInput } from '../../GroupDescriptionInput';
|
||||||
import { GroupTitleInput } from '../../GroupTitleInput';
|
import { GroupTitleInput } from '../../GroupTitleInput';
|
||||||
import * as log from '../../../logging/log';
|
import * as log from '../../../logging/log';
|
||||||
import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer';
|
import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer';
|
||||||
|
@ -24,10 +25,12 @@ const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
|
||||||
|
|
||||||
type PropsType = {
|
type PropsType = {
|
||||||
avatarPath?: string;
|
avatarPath?: string;
|
||||||
|
groupDescription?: string;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
makeRequest: (
|
makeRequest: (
|
||||||
_: Readonly<{
|
_: Readonly<{
|
||||||
avatar?: undefined | ArrayBuffer;
|
avatar?: undefined | ArrayBuffer;
|
||||||
|
description?: string;
|
||||||
title?: undefined | string;
|
title?: undefined | string;
|
||||||
}>
|
}>
|
||||||
) => void;
|
) => void;
|
||||||
|
@ -38,6 +41,7 @@ type PropsType = {
|
||||||
|
|
||||||
export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
||||||
avatarPath: externalAvatarPath,
|
avatarPath: externalAvatarPath,
|
||||||
|
groupDescription: externalGroupDescription = '',
|
||||||
i18n,
|
i18n,
|
||||||
makeRequest,
|
makeRequest,
|
||||||
onClose,
|
onClose,
|
||||||
|
@ -51,9 +55,13 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
||||||
externalAvatarPath ? TEMPORARY_AVATAR_VALUE : undefined
|
externalAvatarPath ? TEMPORARY_AVATAR_VALUE : undefined
|
||||||
);
|
);
|
||||||
const [rawTitle, setRawTitle] = useState(externalTitle);
|
const [rawTitle, setRawTitle] = useState(externalTitle);
|
||||||
|
const [rawGroupDescription, setRawGroupDescription] = useState(
|
||||||
|
externalGroupDescription
|
||||||
|
);
|
||||||
const [hasAvatarChanged, setHasAvatarChanged] = useState(false);
|
const [hasAvatarChanged, setHasAvatarChanged] = useState(false);
|
||||||
|
|
||||||
const trimmedTitle = rawTitle.trim();
|
const trimmedTitle = rawTitle.trim();
|
||||||
|
const trimmedDescription = rawGroupDescription.trim();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const startingAvatarPath = startingAvatarPathRef.current;
|
const startingAvatarPath = startingAvatarPathRef.current;
|
||||||
|
@ -88,12 +96,17 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
||||||
startingAvatarPathRef.current !== externalAvatarPath ||
|
startingAvatarPathRef.current !== externalAvatarPath ||
|
||||||
startingTitleRef.current !== externalTitle;
|
startingTitleRef.current !== externalTitle;
|
||||||
const hasTitleChanged = trimmedTitle !== externalTitle.trim();
|
const hasTitleChanged = trimmedTitle !== externalTitle.trim();
|
||||||
|
const hasGroupDescriptionChanged =
|
||||||
|
externalGroupDescription.trim() !== trimmedDescription;
|
||||||
|
|
||||||
const isRequestActive = requestState === RequestState.Active;
|
const isRequestActive = requestState === RequestState.Active;
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
!isRequestActive &&
|
!isRequestActive &&
|
||||||
(hasChangedExternally || hasTitleChanged || hasAvatarChanged) &&
|
(hasChangedExternally ||
|
||||||
|
hasTitleChanged ||
|
||||||
|
hasAvatarChanged ||
|
||||||
|
hasGroupDescriptionChanged) &&
|
||||||
trimmedTitle.length > 0;
|
trimmedTitle.length > 0;
|
||||||
|
|
||||||
const onSubmit: FormEventHandler<HTMLFormElement> = event => {
|
const onSubmit: FormEventHandler<HTMLFormElement> = event => {
|
||||||
|
@ -101,6 +114,7 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
||||||
|
|
||||||
const request: {
|
const request: {
|
||||||
avatar?: undefined | ArrayBuffer;
|
avatar?: undefined | ArrayBuffer;
|
||||||
|
description?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
} = {};
|
} = {};
|
||||||
if (hasAvatarChanged) {
|
if (hasAvatarChanged) {
|
||||||
|
@ -109,29 +123,23 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
||||||
if (hasTitleChanged) {
|
if (hasTitleChanged) {
|
||||||
request.title = trimmedTitle;
|
request.title = trimmedTitle;
|
||||||
}
|
}
|
||||||
|
if (hasGroupDescriptionChanged) {
|
||||||
|
request.description = trimmedDescription;
|
||||||
|
}
|
||||||
makeRequest(request);
|
makeRequest(request);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalHost onClose={onClose}>
|
<Modal
|
||||||
|
hasXButton
|
||||||
|
i18n={i18n}
|
||||||
|
onClose={onClose}
|
||||||
|
title={i18n('updateGroupAttributes__title')}
|
||||||
|
>
|
||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className="module-EditConversationAttributesModal"
|
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
|
<AvatarInput
|
||||||
contextMenuId="edit conversation attributes avatar input"
|
contextMenuId="edit conversation attributes avatar input"
|
||||||
disabled={isRequestActive}
|
disabled={isRequestActive}
|
||||||
|
@ -151,13 +159,24 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
||||||
value={rawTitle}
|
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 && (
|
{requestState === RequestState.InactiveWithError && (
|
||||||
<div className="module-EditConversationAttributesModal__error-message">
|
<div className="module-EditConversationAttributesModal__error-message">
|
||||||
{i18n('updateGroupAttributes__error-message')}
|
{i18n('updateGroupAttributes__error-message')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="module-EditConversationAttributesModal__button-container">
|
<Modal.ButtonFooter>
|
||||||
<Button
|
<Button
|
||||||
disabled={isRequestActive}
|
disabled={isRequestActive}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
@ -177,9 +196,9 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
||||||
i18n('save')
|
i18n('save')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Modal.ButtonFooter>
|
||||||
</form>
|
</form>
|
||||||
</ModalHost>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -44,24 +44,6 @@ export const GroupV2Permissions: React.ComponentType<PropsType> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelSection>
|
<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
|
<PanelRow
|
||||||
label={i18n('ConversationDetails--add-members-label')}
|
label={i18n('ConversationDetails--add-members-label')}
|
||||||
info={i18n('ConversationDetails--add-members-info')}
|
info={i18n('ConversationDetails--add-members-info')}
|
||||||
|
@ -80,6 +62,24 @@ export const GroupV2Permissions: React.ComponentType<PropsType> = ({
|
||||||
</div>
|
</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>
|
</PanelSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -828,6 +828,29 @@ export function renderChangeDetail(
|
||||||
}
|
}
|
||||||
return renderString('GroupV2--group-link-remove--unknown', i18n);
|
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);
|
throw missingCaseError(detail);
|
||||||
}
|
}
|
||||||
|
|
133
ts/groups.ts
133
ts/groups.ts
|
@ -178,6 +178,10 @@ export type GroupV2AdminApprovalRemoveOneChangeType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
inviter?: string;
|
inviter?: string;
|
||||||
};
|
};
|
||||||
|
export type GroupV2DescriptionChangeType = {
|
||||||
|
type: 'description';
|
||||||
|
removed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type GroupV2ChangeDetailType =
|
export type GroupV2ChangeDetailType =
|
||||||
| GroupV2AccessAttributesChangeType
|
| GroupV2AccessAttributesChangeType
|
||||||
|
@ -187,9 +191,10 @@ export type GroupV2ChangeDetailType =
|
||||||
| GroupV2AdminApprovalAddOneChangeType
|
| GroupV2AdminApprovalAddOneChangeType
|
||||||
| GroupV2AdminApprovalRemoveOneChangeType
|
| GroupV2AdminApprovalRemoveOneChangeType
|
||||||
| GroupV2AvatarChangeType
|
| GroupV2AvatarChangeType
|
||||||
|
| GroupV2DescriptionChangeType
|
||||||
| GroupV2GroupLinkAddChangeType
|
| GroupV2GroupLinkAddChangeType
|
||||||
| GroupV2GroupLinkResetChangeType
|
|
||||||
| GroupV2GroupLinkRemoveChangeType
|
| GroupV2GroupLinkRemoveChangeType
|
||||||
|
| GroupV2GroupLinkResetChangeType
|
||||||
| GroupV2MemberAddChangeType
|
| GroupV2MemberAddChangeType
|
||||||
| GroupV2MemberAddFromAdminApprovalChangeType
|
| GroupV2MemberAddFromAdminApprovalChangeType
|
||||||
| GroupV2MemberAddFromInviteChangeType
|
| GroupV2MemberAddFromInviteChangeType
|
||||||
|
@ -251,12 +256,13 @@ type UploadedAvatarType = {
|
||||||
|
|
||||||
export const MASTER_KEY_LENGTH = 32;
|
export const MASTER_KEY_LENGTH = 32;
|
||||||
const GROUP_TITLE_MAX_ENCRYPTED_BYTES = 1024;
|
const GROUP_TITLE_MAX_ENCRYPTED_BYTES = 1024;
|
||||||
|
const GROUP_DESC_MAX_ENCRYPTED_BYTES = 8192;
|
||||||
export const ID_V1_LENGTH = 16;
|
export const ID_V1_LENGTH = 16;
|
||||||
export const ID_LENGTH = 32;
|
export const ID_LENGTH = 32;
|
||||||
const TEMPORAL_AUTH_REJECTED_CODE = 401;
|
const TEMPORAL_AUTH_REJECTED_CODE = 401;
|
||||||
const GROUP_ACCESS_DENIED_CODE = 403;
|
const GROUP_ACCESS_DENIED_CODE = 403;
|
||||||
const GROUP_NONEXISTENT_CODE = 404;
|
const GROUP_NONEXISTENT_CODE = 404;
|
||||||
const SUPPORTED_CHANGE_EPOCH = 1;
|
const SUPPORTED_CHANGE_EPOCH = 2;
|
||||||
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
|
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
|
||||||
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
|
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
|
||||||
|
|
||||||
|
@ -415,6 +421,25 @@ function buildGroupTitleBuffer(
|
||||||
return result;
|
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(
|
function buildGroupProto(
|
||||||
attributes: Pick<
|
attributes: Pick<
|
||||||
ConversationAttributesType,
|
ConversationAttributesType,
|
||||||
|
@ -716,6 +741,7 @@ export async function buildUpdateAttributesChange(
|
||||||
>,
|
>,
|
||||||
attributes: Readonly<{
|
attributes: Readonly<{
|
||||||
avatar?: undefined | ArrayBuffer;
|
avatar?: undefined | ArrayBuffer;
|
||||||
|
description?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
}>
|
}>
|
||||||
): Promise<undefined | GroupChangeClass.Actions> {
|
): 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) {
|
if (!hasChangedSomething) {
|
||||||
// This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning
|
// This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning
|
||||||
// will be logged.
|
// 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
|
// No disappearing message timer check here - see below
|
||||||
|
|
||||||
// membersV2
|
// 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) {
|
if (ourConversationId) {
|
||||||
result.left = !members[ourConversationId];
|
result.left = !members[ourConversationId];
|
||||||
}
|
}
|
||||||
|
@ -4669,6 +4727,14 @@ async function applyGroupState({
|
||||||
result.groupInviteLinkPassword = undefined;
|
result.groupInviteLinkPassword = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// descriptionBytes
|
||||||
|
const { descriptionBytes } = groupState;
|
||||||
|
if (descriptionBytes && descriptionBytes.content === 'descriptionText') {
|
||||||
|
result.description = descriptionBytes.descriptionText;
|
||||||
|
} else {
|
||||||
|
result.description = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
newAttributes: result,
|
newAttributes: result,
|
||||||
newProfileKeys,
|
newProfileKeys,
|
||||||
|
@ -5219,6 +5285,29 @@ function decryptGroupChange(
|
||||||
actions.modifyInviteLinkPassword = undefined;
|
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;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5240,6 +5329,26 @@ export function decryptGroupTitle(
|
||||||
return undefined;
|
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(
|
function decryptGroupState(
|
||||||
groupState: GroupClass,
|
groupState: GroupClass,
|
||||||
groupSecretParams: string,
|
groupSecretParams: string,
|
||||||
|
@ -5349,6 +5458,26 @@ function decryptGroupState(
|
||||||
groupState.inviteLinkPassword = undefined;
|
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;
|
return groupState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
applyNewAvatar,
|
applyNewAvatar,
|
||||||
|
decryptGroupDescription,
|
||||||
decryptGroupTitle,
|
decryptGroupTitle,
|
||||||
deriveGroupFields,
|
deriveGroupFields,
|
||||||
getPreJoinGroupInfo,
|
getPreJoinGroupInfo,
|
||||||
|
@ -123,6 +124,10 @@ export async function joinViaLink(hash: string): Promise<void> {
|
||||||
const title =
|
const title =
|
||||||
decryptGroupTitle(result.title, secretParams) ||
|
decryptGroupTitle(result.title, secretParams) ||
|
||||||
window.i18n('unknownGroup');
|
window.i18n('unknownGroup');
|
||||||
|
const groupDescription = decryptGroupDescription(
|
||||||
|
result.descriptionBytes,
|
||||||
|
secretParams
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
approvalRequired &&
|
approvalRequired &&
|
||||||
|
@ -162,6 +167,7 @@ export async function joinViaLink(hash: string): Promise<void> {
|
||||||
return {
|
return {
|
||||||
approvalRequired,
|
approvalRequired,
|
||||||
avatar,
|
avatar,
|
||||||
|
groupDescription,
|
||||||
memberCount,
|
memberCount,
|
||||||
title,
|
title,
|
||||||
};
|
};
|
||||||
|
|
1
ts/model-types.d.ts
vendored
1
ts/model-types.d.ts
vendored
|
@ -292,6 +292,7 @@ export type ConversationAttributesType = {
|
||||||
path: string;
|
path: string;
|
||||||
hash?: string;
|
hash?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
description?: string;
|
||||||
expireTimer?: number;
|
expireTimer?: number;
|
||||||
membersV2?: Array<GroupV2MemberType>;
|
membersV2?: Array<GroupV2MemberType>;
|
||||||
pendingMembersV2?: Array<GroupV2PendingMemberType>;
|
pendingMembersV2?: Array<GroupV2PendingMemberType>;
|
||||||
|
|
|
@ -1476,6 +1476,7 @@ export class ConversationModel extends window.Backbone
|
||||||
draftPreview,
|
draftPreview,
|
||||||
draftText,
|
draftText,
|
||||||
firstName: this.get('profileName')!,
|
firstName: this.get('profileName')!,
|
||||||
|
groupDescription: this.get('description'),
|
||||||
groupVersion,
|
groupVersion,
|
||||||
groupId: this.get('groupId'),
|
groupId: this.get('groupId'),
|
||||||
groupLink: this.getGroupLink(),
|
groupLink: this.getGroupLink(),
|
||||||
|
@ -1912,6 +1913,7 @@ export class ConversationModel extends window.Backbone
|
||||||
async updateGroupAttributesV2(
|
async updateGroupAttributesV2(
|
||||||
attributes: Readonly<{
|
attributes: Readonly<{
|
||||||
avatar?: undefined | ArrayBuffer;
|
avatar?: undefined | ArrayBuffer;
|
||||||
|
description?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
}>
|
}>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
|
@ -140,6 +140,7 @@ export type ConversationType = {
|
||||||
draftPreview?: string;
|
draftPreview?: string;
|
||||||
|
|
||||||
sharedGroupNames: Array<string>;
|
sharedGroupNames: Array<string>;
|
||||||
|
groupDescription?: string;
|
||||||
groupVersion?: 1 | 2;
|
groupVersion?: 1 | 2;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
groupLink?: string;
|
groupLink?: string;
|
||||||
|
@ -239,6 +240,7 @@ export type PreJoinConversationType = {
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
url?: string;
|
url?: string;
|
||||||
};
|
};
|
||||||
|
groupDescription?: string;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
title: string;
|
title: string;
|
||||||
approvalRequired: boolean;
|
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>;
|
membersPendingProfileKey?: Array<MemberPendingProfileKeyClass>;
|
||||||
membersPendingAdminApproval?: Array<MemberPendingAdminApprovalClass>;
|
membersPendingAdminApproval?: Array<MemberPendingAdminApprovalClass>;
|
||||||
inviteLinkPassword?: ProtoBinaryType;
|
inviteLinkPassword?: ProtoBinaryType;
|
||||||
|
descriptionBytes?: ProtoBinaryType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare class GroupChangeClass {
|
export declare class GroupChangeClass {
|
||||||
|
@ -322,6 +323,7 @@ export declare namespace GroupChangeClass {
|
||||||
deleteMemberPendingAdminApprovals?: Array<GroupChangeClass.Actions.DeleteMemberPendingAdminApprovalAction>;
|
deleteMemberPendingAdminApprovals?: Array<GroupChangeClass.Actions.DeleteMemberPendingAdminApprovalAction>;
|
||||||
promoteMemberPendingAdminApprovals?: Array<GroupChangeClass.Actions.PromoteMemberPendingAdminApprovalAction>;
|
promoteMemberPendingAdminApprovals?: Array<GroupChangeClass.Actions.PromoteMemberPendingAdminApprovalAction>;
|
||||||
modifyInviteLinkPassword?: GroupChangeClass.Actions.ModifyInviteLinkPasswordAction;
|
modifyInviteLinkPassword?: GroupChangeClass.Actions.ModifyInviteLinkPasswordAction;
|
||||||
|
modifyDescription?: GroupChangeClass.Actions.ModifyDescriptionAction;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -405,6 +407,10 @@ export declare namespace GroupChangeClass.Actions {
|
||||||
class ModifyInviteLinkPasswordAction {
|
class ModifyInviteLinkPasswordAction {
|
||||||
inviteLinkPassword?: ProtoBinaryType;
|
inviteLinkPassword?: ProtoBinaryType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ModifyDescriptionAction {
|
||||||
|
descriptionBytes?: ProtoBinaryType;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare class GroupChangesClass {
|
export declare class GroupChangesClass {
|
||||||
|
@ -434,10 +440,15 @@ export declare class GroupAttributeBlobClass {
|
||||||
title?: string;
|
title?: string;
|
||||||
avatar?: ProtoBinaryType;
|
avatar?: ProtoBinaryType;
|
||||||
disappearingMessagesDuration?: number;
|
disappearingMessagesDuration?: number;
|
||||||
|
descriptionText?: string;
|
||||||
|
|
||||||
// Note: this isn't part of the proto, but our protobuf library tells us which
|
// Note: this isn't part of the proto, but our protobuf library tells us which
|
||||||
// field has been set with this prop.
|
// field has been set with this prop.
|
||||||
content: 'title' | 'avatar' | 'disappearingMessagesDuration';
|
content:
|
||||||
|
| 'title'
|
||||||
|
| 'avatar'
|
||||||
|
| 'disappearingMessagesDuration'
|
||||||
|
| 'descriptionText';
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare class GroupExternalCredentialClass {
|
export declare class GroupExternalCredentialClass {
|
||||||
|
@ -483,6 +494,7 @@ export declare class GroupJoinInfoClass {
|
||||||
addFromInviteLink?: AccessControlClass.AccessRequired;
|
addFromInviteLink?: AccessControlClass.AccessRequired;
|
||||||
version?: number;
|
version?: number;
|
||||||
pendingAdminApproval?: boolean;
|
pendingAdminApproval?: boolean;
|
||||||
|
descriptionBytes?: ProtoBinaryType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Previous protos
|
// Previous protos
|
||||||
|
|
|
@ -13767,6 +13767,27 @@
|
||||||
"updated": "2020-11-17T23:29:38.698Z",
|
"updated": "2020-11-17T23:29:38.698Z",
|
||||||
"reasonDetail": "Doesn't touch the DOM."
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/GroupTitleInput.js",
|
"path": "ts/components/GroupTitleInput.js",
|
||||||
|
@ -13951,6 +13972,13 @@
|
||||||
"updated": "2021-04-17T01:47:31.419Z",
|
"updated": "2021-04-17T01:47:31.419Z",
|
||||||
"reasonDetail": "Used for managing playback of GIF video"
|
"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",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/conversation/InlineNotificationWrapper.js",
|
"path": "ts/components/conversation/InlineNotificationWrapper.js",
|
||||||
|
|
Loading…
Add table
Reference in a new issue