diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e9ec3e3836..284e14cb3d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" } } diff --git a/protos/Groups.proto b/protos/Groups.proto index 7823ec8b13..6b0e6df8cc 100644 --- a/protos/Groups.proto +++ b/protos/Groups.proto @@ -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; } diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index cf1629a671..5c65ae435c 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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 diff --git a/stylesheets/components/EditConversationAttributesModal.scss b/stylesheets/components/EditConversationAttributesModal.scss index 42c771034d..52b6507b16 100644 --- a/stylesheets/components/EditConversationAttributesModal.scss +++ b/stylesheets/components/EditConversationAttributesModal.scss @@ -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; } } diff --git a/stylesheets/components/GroupDescription.scss b/stylesheets/components/GroupDescription.scss new file mode 100644 index 0000000000..55c9134f96 --- /dev/null +++ b/stylesheets/components/GroupDescription.scss @@ -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; + } +} diff --git a/stylesheets/components/GroupTitleInput.scss b/stylesheets/components/GroupInput.scss similarity index 67% rename from stylesheets/components/GroupTitleInput.scss rename to stylesheets/components/GroupInput.scss index 3c8a46bf11..17cd2a344b 100644 --- a/stylesheets/components/GroupTitleInput.scss +++ b/stylesheets/components/GroupInput.scss @@ -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; + } } diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index ae0c055ae8..69413fa1fa 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/components/GroupDescriptionInput.tsx b/ts/components/GroupDescriptionInput.tsx new file mode 100644 index 0000000000..7971f301ed --- /dev/null +++ b/ts/components/GroupDescriptionInput.tsx @@ -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( + ({ i18n, disabled = false, onChangeValue, value }, ref) => { + const innerRef = useRef(null); + const valueOnKeydownRef = useRef(value); + const selectionStartOnKeydownRef = useRef(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) => { + 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 ( + <> +
+