From 9f5335b85483356e133d492ed5dacdce04f2c5dc Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 9 Mar 2021 13:16:56 -0600 Subject: [PATCH] New Group administration: update title and avatar --- _locales/en/messages.json | 8 + components/GroupTitleInput.scss | 17 +- images/icons/v2/compose-solid-24.svg | 1 + stylesheets/_modules.scss | 47 +++- stylesheets/components/AvatarInput.scss | 15 ++ .../EditConversationAttributesModal.scss | 81 +++++++ stylesheets/manifest.scss | 1 + ts/components/AvatarInput.stories.tsx | 33 +-- ts/components/AvatarInput.tsx | 28 +-- ts/components/Button.tsx | 34 ++- .../ConversationDetails.stories.tsx | 1 + .../ConversationDetails.tsx | 71 +++++- .../ConversationDetailsHeader.stories.tsx | 16 +- .../ConversationDetailsHeader.tsx | 30 ++- ...ditConversationAttributesModal.stories.tsx | 57 +++++ .../EditConversationAttributesModal.tsx | 209 ++++++++++++++++++ ts/groups.ts | 93 +++++++- ts/models/conversations.ts | 25 +++ ts/state/smart/ConversationDetails.tsx | 6 + ts/test-both/util/characters_test.ts | 19 ++ .../util/canvasToArrayBuffer_test.ts | 27 +++ ts/util/canvasToArrayBuffer.ts | 17 ++ ts/util/characters.ts | 6 + ts/util/lint/exceptions.json | 22 +- ts/views/conversation_view.ts | 3 + 25 files changed, 806 insertions(+), 61 deletions(-) create mode 100644 images/icons/v2/compose-solid-24.svg create mode 100644 stylesheets/components/EditConversationAttributesModal.scss create mode 100644 ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx create mode 100644 ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx create mode 100644 ts/test-both/util/characters_test.ts create mode 100644 ts/test-electron/util/canvasToArrayBuffer_test.ts create mode 100644 ts/util/canvasToArrayBuffer.ts create mode 100644 ts/util/characters.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7783dfb36138..b9714032b04b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1987,6 +1987,14 @@ "message": "This group couldn’t be created. Check your connection and try again.", "description": "Shown in the modal when we can't create a group" }, + "updateGroupAttributes__title": { + "message": "Edit group name and photo", + "description": "Shown in the modal when we want to update a group" + }, + "updateGroupAttributes__error-message": { + "message": "Failed to update the group. Check your connection and try again.", + "description": "Shown in the modal when we can't update a group" + }, "notSupportedSMS": { "message": "SMS/MMS messages are not supported.", "description": "Label underneath number informing user that SMS is not supported on desktop" diff --git a/components/GroupTitleInput.scss b/components/GroupTitleInput.scss index 0f93e431236e..0e70d7f2575f 100644 --- a/components/GroupTitleInput.scss +++ b/components/GroupTitleInput.scss @@ -6,9 +6,20 @@ @include font-body-1; padding: 8px 12px; border-radius: 6px; - border: 2px solid $color-gray-15; - background: $color-white; - color: $color-black; + border-width: 2px; + border-style: solid; + + @include light-theme { + background: $color-white; + color: $color-black; + border-color: $color-gray-15; + } + + @include dark-theme { + background: $color-gray-80; + color: $color-gray-05; + border-color: $color-gray-45; + } &:focus { outline: none; diff --git a/images/icons/v2/compose-solid-24.svg b/images/icons/v2/compose-solid-24.svg new file mode 100644 index 000000000000..7efa2cbc7444 --- /dev/null +++ b/images/icons/v2/compose-solid-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 0d96dee1b26d..60aadeeea5c0 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2871,18 +2871,31 @@ button.module-conversation-details__action-button { .module-conversation-details { &-header { - &__root { + &__root, + &__root--editable { align-items: center; + background: none; + border: none; + color: inherit; display: flex; flex-direction: column; - padding-bottom: 24px; + margin: 0; + outline: inherit; + padding: 0 0 24px 0; text-align: center; + width: 100%; + } + + &__root--editable { + cursor: pointer; } &__title { @include font-title-1; - padding-top: 12px; + align-items: center; + display: flex; padding-bottom: 8px; + padding-top: 12px; } &__subtitle { @@ -2894,6 +2907,34 @@ button.module-conversation-details__action-button { color: $color-gray-25; } } + + &__root--editable &__title { + $icon: '../images/icons/v2/compose-solid-24.svg'; + + &::after { + $size: 24px; + + content: ''; + height: $size; + left: $size + 13px; + margin-left: -$size; + opacity: 0; + position: relative; + transition: opacity 100ms ease-out; + width: $size; + + @include light-theme { + @include color-svg($icon, $color-gray-60); + } + @include dark-theme { + @include color-svg($icon, $color-gray-25); + } + } + } + + &__root--editable:hover &__title::after { + opacity: 1; + } } &__leave-group { diff --git a/stylesheets/components/AvatarInput.scss b/stylesheets/components/AvatarInput.scss index df781fcf0b7e..c355ba9d1fc9 100644 --- a/stylesheets/components/AvatarInput.scss +++ b/stylesheets/components/AvatarInput.scss @@ -3,12 +3,15 @@ .module-AvatarInput { @include button-reset; + display: flex; flex-direction: column; align-items: center; width: 100%; background: none; + $dark-selector: '#{&}--dark'; + &__avatar { @include button-reset; @@ -23,6 +26,10 @@ align-items: stretch; background: $color-white; + @at-root '#{$dark-selector} #{&}' { + background: $ultramarine-ui-light; + } + &::before { flex-grow: 1; content: ''; @@ -33,6 +40,14 @@ false ); -webkit-mask-size: 24px 24px; + + @at-root '#{$dark-selector} #{&}' { + @include color-svg( + '../images/icons/v2/camera-outline-24.svg', + $color-white, + false + ); + } } } diff --git a/stylesheets/components/EditConversationAttributesModal.scss b/stylesheets/components/EditConversationAttributesModal.scss new file mode 100644 index 000000000000..15f6e317bc71 --- /dev/null +++ b/stylesheets/components/EditConversationAttributesModal.scss @@ -0,0 +1,81 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-EditConversationAttributesModal { + @include popper-shadow(); + border-radius: 8px; + margin: 0 auto; + max-height: 100%; + max-width: 360px; + padding: 16px; + position: relative; + width: 95%; + display: flex; + flex-direction: column; + + @include light-theme() { + background: $color-white; + color: $color-gray-90; + } + + @include dark-theme() { + background: $color-gray-95; + color: $color-gray-05; + } + + &__close-button { + @include button-reset; + + position: absolute; + right: 12px; + top: 12px; + + height: 24px; + width: 24px; + + @include light-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); + } + + @include dark-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); + } + + &:focus { + @include keyboard-mode { + background-color: $ultramarine-ui-light; + } + @include dark-keyboard-mode { + background-color: $ultramarine-ui-dark; + } + } + } + + &__header { + @include font-body-1-bold; + margin: 0; + } + + .module-AvatarInput { + margin: 40px 0 24px 0; + } + + &__error-message { + @include font-body-1; + 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; + } + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 6316ea70afcd..a58c3d57cf76 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -33,5 +33,6 @@ @import './components/ContactPill.scss'; @import './components/ContactPills.scss'; @import './components/ConversationHeader.scss'; +@import './components/EditConversationAttributesModal.scss'; @import './components/GroupDialog.scss'; @import './components/GroupTitleInput.scss'; diff --git a/ts/components/AvatarInput.stories.tsx b/ts/components/AvatarInput.stories.tsx index 0087dc871ebe..14949c22ca82 100644 --- a/ts/components/AvatarInput.stories.tsx +++ b/ts/components/AvatarInput.stories.tsx @@ -9,7 +9,7 @@ import { storiesOf } from '@storybook/react'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; -import { AvatarInput } from './AvatarInput'; +import { AvatarInput, AvatarInputVariant } from './AvatarInput'; const i18n = setupI18n('en', enMessages); @@ -22,7 +22,13 @@ const TEST_IMAGE = new Uint8Array( ).map(bytePair => parseInt(bytePair.join(''), 16)) ).buffer; -const Wrapper = ({ startValue }: { startValue: undefined | ArrayBuffer }) => { +const Wrapper = ({ + startValue, + variant, +}: { + startValue: undefined | ArrayBuffer; + variant?: AvatarInputVariant; +}) => { const [value, setValue] = useState(startValue); const [objectUrl, setObjectUrl] = useState(); @@ -40,18 +46,13 @@ const Wrapper = ({ startValue }: { startValue: undefined | ArrayBuffer }) => { return ( <> -
- -
+
Processed image (if it exists)
{objectUrl && } @@ -67,3 +68,7 @@ story.add('No start state', () => { story.add('Starting with a value', () => { return ; }); + +story.add('Dark variant', () => { + return ; +}); diff --git a/ts/components/AvatarInput.tsx b/ts/components/AvatarInput.tsx index 3de1399792f5..9944540ad609 100644 --- a/ts/components/AvatarInput.tsx +++ b/ts/components/AvatarInput.tsx @@ -9,12 +9,14 @@ import React, { MouseEventHandler, FunctionComponent, } from 'react'; +import classNames from 'classnames'; import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu'; import loadImage, { LoadImageOptions } from 'blueimp-load-image'; import { noop } from 'lodash'; import { LocalizerType } from '../types/Util'; import { Spinner } from './Spinner'; +import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer'; type PropsType = { // This ID needs to be globally unique across the app. @@ -23,6 +25,7 @@ type PropsType = { i18n: LocalizerType; onChange: (value: undefined | ArrayBuffer) => unknown; value: undefined | ArrayBuffer; + variant?: AvatarInputVariant; }; enum ImageStatus { @@ -31,12 +34,18 @@ enum ImageStatus { HasImage = 'has-image', } +export enum AvatarInputVariant { + Light = 'light', + Dark = 'dark', +} + export const AvatarInput: FunctionComponent = ({ contextMenuId, disabled, i18n, onChange, value, + variant = AvatarInputVariant.Light, }) => { const fileInputRef = useRef(null); // Comes from a third-party dependency @@ -136,7 +145,10 @@ export const AvatarInput: FunctionComponent = ({ diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 99a816eb1f49..457ff6bce409 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -60,6 +60,7 @@ const createProps = (hasGroupLink = false): Props => ({ showGroupV2Permissions: action('showGroupV2Permissions'), showPendingInvites: action('showPendingInvites'), showLightboxForMedia: action('showLightboxForMedia'), + updateGroupAttributes: action('updateGroupAttributes'), onBlockAndDelete: action('onBlockAndDelete'), onDelete: action('onDelete'), }); diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 7a5897b23a93..9d150412e7d1 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -1,7 +1,7 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useState } from 'react'; import { ConversationType } from '../../../state/ducks/conversations'; import { @@ -18,6 +18,10 @@ import { ConversationDetailsHeader } from './ConversationDetailsHeader'; import { ConversationDetailsIcon } from './ConversationDetailsIcon'; import { ConversationDetailsMediaList } from './ConversationDetailsMediaList'; import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList'; +import { + EditConversationAttributesModal, + RequestState as EditGroupAttributesRequestState, +} from './EditConversationAttributesModal'; export type StateProps = { canEditGroupInfo: boolean; @@ -36,6 +40,12 @@ export type StateProps = { selectedMediaItem: MediaItemType, media: Array ) => void; + updateGroupAttributes: ( + _: Readonly<{ + avatar?: undefined | ArrayBuffer; + title?: string; + }> + ) => void; onBlockAndDelete: () => void; onDelete: () => void; }; @@ -56,9 +66,20 @@ export const ConversationDetails: React.ComponentType = ({ showGroupV2Permissions, showPendingInvites, showLightboxForMedia, + updateGroupAttributes, onBlockAndDelete, onDelete, }) => { + const [isEditingGroupAttributes, setIsEditingGroupAttributes] = useState( + false + ); + const [ + editGroupAttributesRequestState, + setEditGroupAttributesRequestState, + ] = useState( + EditGroupAttributesRequestState.Inactive + ); + const updateExpireTimer = (event: React.ChangeEvent) => { setDisappearingMessages(parseInt(event.target.value, 10)); }; @@ -75,7 +96,14 @@ export const ConversationDetails: React.ComponentType = ({ return (
- + { + setIsEditingGroupAttributes(true); + }} + /> {canEditGroupInfo ? ( @@ -171,6 +199,43 @@ export const ConversationDetails: React.ComponentType = ({ onDelete={onDelete} onBlockAndDelete={onBlockAndDelete} /> + + {isEditingGroupAttributes && ( + + ) => { + setEditGroupAttributesRequestState( + EditGroupAttributesRequestState.Active + ); + + try { + await updateGroupAttributes(options); + setIsEditingGroupAttributes(false); + setEditGroupAttributesRequestState( + EditGroupAttributesRequestState.Inactive + ); + } catch (err) { + setEditGroupAttributesRequestState( + EditGroupAttributesRequestState.InactiveWithError + ); + } + }} + onClose={() => { + setIsEditingGroupAttributes(false); + setEditGroupAttributesRequestState( + EditGroupAttributesRequestState.Inactive + ); + }} + requestState={editGroupAttributesRequestState} + title={conversation.title} + /> + )}
); }; diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx index 9bbe6ea285dd..c869286f8b0f 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx @@ -1,9 +1,10 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; import { number, text } from '@storybook/addon-knobs'; import { setup as setupI18n } from '../../../../js/modules/i18n'; @@ -15,7 +16,7 @@ import { ConversationDetailsHeader, Props } from './ConversationDetailsHeader'; const i18n = setupI18n('en', enMessages); const story = storiesOf( - 'Components/Conversation/ConversationDetails/ConversationDetailHeader', + 'Components/Conversation/ConversationDetails/ConversationDetailsHeader', module ); @@ -28,9 +29,12 @@ const createConversation = (): ConversationType => ({ memberships: new Array(number('conversation members length', 0)), }); -const createProps = (): Props => ({ +const createProps = (overrideProps: Partial = {}): Props => ({ conversation: createConversation(), i18n, + canEdit: false, + startEditing: action('startEditing'), + ...overrideProps, }); story.add('Basic', () => { @@ -38,3 +42,9 @@ story.add('Basic', () => { return ; }); + +story.add('Editable', () => { + const props = createProps({ canEdit: true }); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx index 25111fcf2eed..2fd6dc93c2fd 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -9,20 +9,24 @@ import { ConversationType } from '../../../state/ducks/conversations'; import { bemGenerator } from './util'; export type Props = { - i18n: LocalizerType; + canEdit: boolean; conversation: ConversationType; + i18n: LocalizerType; + startEditing: () => void; }; const bem = bemGenerator('module-conversation-details-header'); export const ConversationDetailsHeader: React.ComponentType = ({ - i18n, + canEdit, conversation, + i18n, + startEditing, }) => { const memberships = conversation.memberships || []; - return ( -
+ const contents = ( + <> = ({ ])}
- + ); + + if (canEdit) { + return ( + + ); + } + + return
{contents}
; }; diff --git a/ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx b/ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx new file mode 100644 index 000000000000..2ca43fcfe816 --- /dev/null +++ b/ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx @@ -0,0 +1,57 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ComponentProps } from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../../js/modules/i18n'; +import enMessages from '../../../../_locales/en/messages.json'; +import { + EditConversationAttributesModal, + RequestState, +} from './EditConversationAttributesModal'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/EditConversationAttributesModal', + module +); + +type PropsType = ComponentProps; + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + avatarPath: undefined, + i18n, + onClose: action('onClose'), + makeRequest: action('onMakeRequest'), + requestState: RequestState.Inactive, + title: 'Bing Bong Group', + ...overrideProps, +}); + +story.add('No avatar, empty title', () => ( + +)); + +story.add('Avatar and title', () => ( + +)); + +story.add('Request active', () => ( + +)); + +story.add('Has error', () => ( + +)); diff --git a/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx b/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx new file mode 100644 index 000000000000..3a46ccde66d1 --- /dev/null +++ b/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx @@ -0,0 +1,209 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { + FormEventHandler, + FunctionComponent, + useEffect, + useRef, + useState, +} from 'react'; +import { noop } from 'lodash'; + +import { LocalizerType } from '../../../types/Util'; +import { ModalHost } from '../../ModalHost'; +import { AvatarInput, AvatarInputVariant } from '../../AvatarInput'; +import { Button, ButtonVariant } from '../../Button'; +import { Spinner } from '../../Spinner'; +import { GroupTitleInput } from '../../GroupTitleInput'; +import * as log from '../../../logging/log'; +import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer'; + +const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0); + +type PropsType = { + avatarPath?: string; + i18n: LocalizerType; + makeRequest: ( + _: Readonly<{ + avatar?: undefined | ArrayBuffer; + title?: undefined | string; + }> + ) => void; + onClose: () => void; + requestState: RequestState; + title: string; +}; + +export enum RequestState { + Inactive, + InactiveWithError, + Active, +} + +export const EditConversationAttributesModal: FunctionComponent = ({ + avatarPath: externalAvatarPath, + i18n, + makeRequest, + onClose, + requestState, + title: externalTitle, +}) => { + const startingTitleRef = useRef(externalTitle); + const startingAvatarPathRef = useRef(externalAvatarPath); + + const [avatar, setAvatar] = useState( + externalAvatarPath ? TEMPORARY_AVATAR_VALUE : undefined + ); + const [title, setTitle] = useState(externalTitle); + const [hasAvatarChanged, setHasAvatarChanged] = useState(false); + + useEffect(() => { + const startingAvatarPath = startingAvatarPathRef.current; + if (!startingAvatarPath) { + return noop; + } + + let shouldCancel = false; + + (async () => { + try { + const buffer = await imagePathToArrayBuffer(startingAvatarPath); + if (shouldCancel) { + return; + } + setAvatar(buffer); + } catch (err) { + log.warn( + `Failed to convert image URL to array buffer. Error message: ${ + err && err.message + }` + ); + } + })(); + + return () => { + shouldCancel = true; + }; + }, []); + + const hasChangedExternally = + startingAvatarPathRef.current !== externalAvatarPath || + startingTitleRef.current !== externalTitle; + const hasTitleChanged = title !== externalTitle; + + const isRequestActive = requestState === RequestState.Active; + + const canSubmit = + !isRequestActive && + (hasChangedExternally || hasTitleChanged || hasAvatarChanged) && + title.length > 0; + + const onSubmit: FormEventHandler = event => { + event.preventDefault(); + + const request: { + avatar?: undefined | ArrayBuffer; + title?: string; + } = {}; + if (hasAvatarChanged) { + request.avatar = avatar; + } + if (hasTitleChanged) { + request.title = title; + } + makeRequest(request); + }; + + return ( + +
+ + + + +
+
+ ); +}; + +async function imagePathToArrayBuffer(src: string): Promise { + const image = new Image(); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error( + 'imagePathToArrayBuffer: could not get canvas rendering context' + ); + } + + image.src = src; + await image.decode(); + + canvas.width = image.width; + canvas.height = image.height; + + context.drawImage(image, 0, 0); + + const result = await canvasToArrayBuffer(canvas); + return result; +} diff --git a/ts/groups.ts b/ts/groups.ts index f628262fa812..909419b7fdf3 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -380,6 +380,16 @@ async function uploadAvatar( } } +function buildGroupTitleBuffer( + clientZkGroupCipher: ClientZkGroupCipher, + title: string +): ArrayBuffer { + const titleBlob = new window.textsecure.protobuf.GroupAttributeBlob(); + titleBlob.title = title; + const titleBlobPlaintext = titleBlob.toArrayBuffer(); + return encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext); +} + function buildGroupProto( attributes: Pick< ConversationAttributesType, @@ -423,10 +433,9 @@ function buildGroupProto( proto.publicKey = base64ToArrayBuffer(publicParams); proto.version = attributes.revision || 0; - const titleBlob = new window.textsecure.protobuf.GroupAttributeBlob(); - titleBlob.title = attributes.name; - const titleBlobPlaintext = titleBlob.toArrayBuffer(); - proto.title = encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext); + if (attributes.name) { + proto.title = buildGroupTitleBuffer(clientZkGroupCipher, attributes.name); + } if (attributes.avatarUrl) { proto.avatar = attributes.avatarUrl; @@ -533,6 +542,82 @@ function buildGroupProto( return proto; } +export async function buildUpdateAttributesChange( + conversation: Pick< + ConversationAttributesType, + 'id' | 'revision' | 'publicParams' | 'secretParams' + >, + attributes: Readonly<{ + avatar?: undefined | ArrayBuffer; + title?: string; + }> +): Promise { + const { publicParams, secretParams, revision, id } = conversation; + + const logId = `groupv2(${id})`; + + if (!publicParams) { + throw new Error( + `buildUpdateAttributesChange/${logId}: attributes were missing publicParams!` + ); + } + if (!secretParams) { + throw new Error( + `buildUpdateAttributesChange/${logId}: attributes were missing secretParams!` + ); + } + + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + + let hasChangedSomething = false; + + const clientZkGroupCipher = getClientZkGroupCipher(secretParams); + + // There are three possible states here: + // + // 1. 'avatar' not in attributes: we don't want to change the avatar. + // 2. attributes.avatar === undefined: we want to clear the avatar. + // 3. attributes.avatar !== undefined: we want to update the avatar. + if ('avatar' in attributes) { + hasChangedSomething = true; + + actions.modifyAvatar = new window.textsecure.protobuf.GroupChange.Actions.ModifyAvatarAction(); + const { avatar } = attributes; + if (avatar) { + const uploadedAvatar = await uploadAvatar({ + data: avatar, + logId, + publicParams, + secretParams, + }); + actions.modifyAvatar.avatar = uploadedAvatar.key; + } + + // If we don't set `actions.modifyAvatar.avatar`, it will be cleared. + } + + const { title } = attributes; + if (title) { + hasChangedSomething = true; + + actions.modifyTitle = new window.textsecure.protobuf.GroupChange.Actions.ModifyTitleAction(); + actions.modifyTitle.title = buildGroupTitleBuffer( + clientZkGroupCipher, + title + ); + } + + if (!hasChangedSomething) { + // This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning + // will be logged. + return undefined; + } + + actions.version = (revision || 0) + 1; + + return actions; +} + export function buildDisappearingMessagesTimerChange({ expireTimer, group, diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 7a5f90e5021c..dd090c6da250 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1716,6 +1716,27 @@ export class ConversationModel extends window.Backbone.Model< }); } + async updateGroupAttributesV2( + attributes: Readonly<{ + avatar?: undefined | ArrayBuffer; + title?: string; + }> + ): Promise { + await this.modifyGroupV2({ + name: 'updateGroupAttributesV2', + createGroupChange: () => + window.Signal.Groups.buildUpdateAttributesChange( + { + id: this.id, + publicParams: this.get('publicParams'), + revision: this.get('revision'), + secretParams: this.get('secretParams'), + }, + attributes + ), + }); + } + async leaveGroupV2(): Promise { const ourConversationId = window.ConversationController.getOurConversationId(); @@ -4818,6 +4839,10 @@ export class ConversationModel extends window.Backbone.Model< return false; } + if (this.get('left')) { + return false; + } + return ( this.areWeAdmin() || this.get('accessControl')?.attributes === diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 12d6012c0a0f..39cf4fb13719 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -26,6 +26,12 @@ export type SmartConversationDetailsProps = { selectedMediaItem: MediaItemType, media: Array ) => void; + updateGroupAttributes: ( + _: Readonly<{ + avatar?: undefined | ArrayBuffer; + title?: string; + }> + ) => void; onBlockAndDelete: () => void; onDelete: () => void; }; diff --git a/ts/test-both/util/characters_test.ts b/ts/test-both/util/characters_test.ts new file mode 100644 index 000000000000..b6d02bde681a --- /dev/null +++ b/ts/test-both/util/characters_test.ts @@ -0,0 +1,19 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { count } from '../../util/characters'; + +describe('character utilities', () => { + describe('count', () => { + it('returns the number of characters in a string (not necessarily the length)', () => { + assert.strictEqual(count(''), 0); + assert.strictEqual(count('hello'), 5); + assert.strictEqual(count('Bokmål'), 6); + assert.strictEqual(count('💩💩💩'), 3); + assert.strictEqual(count('👩‍❤️‍👩'), 6); + assert.strictEqual(count('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘'), 58); + }); + }); +}); diff --git a/ts/test-electron/util/canvasToArrayBuffer_test.ts b/ts/test-electron/util/canvasToArrayBuffer_test.ts new file mode 100644 index 000000000000..5df574c857b6 --- /dev/null +++ b/ts/test-electron/util/canvasToArrayBuffer_test.ts @@ -0,0 +1,27 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { canvasToArrayBuffer } from '../../util/canvasToArrayBuffer'; + +describe('canvasToArrayBuffer', () => { + it('converts a canvas to an ArrayBuffer', async () => { + const canvas = document.createElement('canvas'); + canvas.width = 100; + canvas.height = 200; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Test setup error: cannot get canvas rendering context'); + } + context.fillStyle = '#ff9900'; + context.fillRect(10, 10, 20, 20); + + const result = await canvasToArrayBuffer(canvas); + + // These are just smoke tests. + assert.instanceOf(result, ArrayBuffer); + assert.isAtLeast(result.byteLength, 50); + }); +}); diff --git a/ts/util/canvasToArrayBuffer.ts b/ts/util/canvasToArrayBuffer.ts new file mode 100644 index 000000000000..2d9763f2c70d --- /dev/null +++ b/ts/util/canvasToArrayBuffer.ts @@ -0,0 +1,17 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export async function canvasToArrayBuffer( + canvas: HTMLCanvasElement +): Promise { + const blob: Blob = await new Promise((resolve, reject) => { + canvas.toBlob(result => { + if (result) { + resolve(result); + } else { + reject(new Error("Couldn't convert the canvas to a Blob")); + } + }, 'image/webp'); + }); + return blob.arrayBuffer(); +} diff --git a/ts/util/characters.ts b/ts/util/characters.ts new file mode 100644 index 000000000000..a4e44ef5243b --- /dev/null +++ b/ts/util/characters.ts @@ -0,0 +1,6 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function count(str: string): number { + return Array.from(str).length; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 2f6a29f6c2a5..5e673c1b2762 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14473,7 +14473,7 @@ "rule": "React-useRef", "path": "ts/components/AvatarInput.js", "line": " const fileInputRef = react_1.useRef(null);", - "lineNumber": 40, + "lineNumber": 47, "reasonCategory": "usageTrusted", "updated": "2021-03-01T18:34:36.638Z", "reasonDetail": "Needed to trigger a click on a 'file' input. Doesn't update the DOM." @@ -14482,7 +14482,7 @@ "rule": "React-useRef", "path": "ts/components/AvatarInput.js", "line": " const menuTriggerRef = react_1.useRef(null);", - "lineNumber": 43, + "lineNumber": 50, "reasonCategory": "usageTrusted", "updated": "2021-03-01T18:34:36.638Z", "reasonDetail": "Used to reference popup menu" @@ -15045,6 +15045,24 @@ "updated": "2019-07-31T00:19:18.696Z", "reasonDetail": "Timeline needs to interact with its child List directly" }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js", + "line": " const startingTitleRef = react_1.useRef(externalTitle);", + "lineNumber": 42, + "reasonCategory": "usageTrusted", + "updated": "2021-03-05T22:52:40.572Z", + "reasonDetail": "Doesn't interact with the DOM." + }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js", + "line": " const startingAvatarPathRef = react_1.useRef(externalAvatarPath);", + "lineNumber": 43, + "reasonCategory": "usageTrusted", + "updated": "2021-03-05T22:52:40.572Z", + "reasonDetail": "Doesn't interact with the DOM." + }, { "rule": "React-createRef", "path": "ts/components/conversation/media-gallery/MediaGallery.js", diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 3b36574e5eaf..8d26f35033a3 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -2897,6 +2897,9 @@ Whisper.ConversationView = Whisper.View.extend({ showGroupV2Permissions: this.showGroupV2Permissions.bind(this), showPendingInvites: this.showPendingInvites.bind(this), showLightboxForMedia: this.showLightboxForMedia.bind(this), + updateGroupAttributes: conversation.updateGroupAttributesV2.bind( + conversation + ), onDelete, onBlockAndDelete, };