New Group administration: update title and avatar

This commit is contained in:
Evan Hahn 2021-03-09 13:16:56 -06:00 committed by Josh Perez
parent 468d491d34
commit 9f5335b854
25 changed files with 806 additions and 61 deletions

View File

@ -1987,6 +1987,14 @@
"message": "This group couldnt 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"

View File

@ -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;

View File

@ -0,0 +1 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m21.561 6.682-2.475 2.475-4.243-4.243 2.475-2.475a1.5 1.5 0 0 1 2.121 0l2.122 2.122a1.5 1.5 0 0 1 0 2.121zm-18.132 9.949-1.112 4.445a.5.5 0 0 0 .607.607l4.445-1.112a1.5 1.5 0 0 0 .7-.394l9.959-9.959-4.246-4.243-9.959 9.959a1.5 1.5 0 0 0 -.394.697z"/></svg>

After

Width:  |  Height:  |  Size: 348 B

View File

@ -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 {

View File

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

View File

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

View File

@ -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';

View File

@ -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<undefined | ArrayBuffer>(startValue);
const [objectUrl, setObjectUrl] = useState<undefined | string>();
@ -40,18 +46,13 @@ const Wrapper = ({ startValue }: { startValue: undefined | ArrayBuffer }) => {
return (
<>
<div
style={{
background: 'rgba(255, 0, 255, 0.1)',
}}
>
<AvatarInput
contextMenuId={uuid()}
i18n={i18n}
value={value}
onChange={setValue}
/>
</div>
<AvatarInput
contextMenuId={uuid()}
i18n={i18n}
value={value}
onChange={setValue}
variant={variant}
/>
<figure>
<figcaption>Processed image (if it exists)</figcaption>
{objectUrl && <img src={objectUrl} alt="" />}
@ -67,3 +68,7 @@ story.add('No start state', () => {
story.add('Starting with a value', () => {
return <Wrapper startValue={TEST_IMAGE} />;
});
story.add('Dark variant', () => {
return <Wrapper startValue={undefined} variant={AvatarInputVariant.Dark} />;
});

View File

@ -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<PropsType> = ({
contextMenuId,
disabled,
i18n,
onChange,
value,
variant = AvatarInputVariant.Light,
}) => {
const fileInputRef = useRef<null | HTMLInputElement>(null);
// Comes from a third-party dependency
@ -136,7 +145,10 @@ export const AvatarInput: FunctionComponent<PropsType> = ({
<button
type="button"
disabled={disabled || isLoading}
className="module-AvatarInput"
className={classNames(
'module-AvatarInput',
`module-AvatarInput--${variant}`
)}
onClick={onClick}
>
<div
@ -197,17 +209,5 @@ async function processFile(file: File): Promise<ArrayBuffer> {
throw new Error('Loaded image was not a canvas');
}
return (await canvasToBlob(image)).arrayBuffer();
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error("Couldn't convert the canvas to a Blob"));
}
}, 'image/webp');
});
return canvasToArrayBuffer(image);
}

View File

@ -16,9 +16,15 @@ type PropsType = {
children: ReactNode;
className?: string;
disabled?: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
variant?: ButtonVariant;
};
} & (
| {
onClick: MouseEventHandler<HTMLButtonElement>;
}
| {
type: 'submit';
}
);
const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([
[ButtonVariant.Primary, 'module-Button--primary'],
@ -27,16 +33,24 @@ const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([
]);
export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
(
{
(props, ref) => {
const {
children,
className,
disabled = false,
onClick,
variant = ButtonVariant.Primary,
},
ref
) => {
} = props;
let onClick: undefined | MouseEventHandler<HTMLButtonElement>;
let type: 'button' | 'submit';
if ('onClick' in props) {
({ onClick } = props);
type = 'button';
} else {
onClick = undefined;
({ type } = props);
}
const variantClassName = VARIANT_CLASS_NAMES.get(variant);
assert(variantClassName, '<Button> variant not found');
@ -46,7 +60,9 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
disabled={disabled}
onClick={onClick}
ref={ref}
type="button"
// The `type` should either be "button" or "submit", which is effectively static.
// eslint-disable-next-line react/button-has-type
type={type}
>
{children}
</button>

View File

@ -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'),
});

View File

@ -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<MediaItemType>
) => void;
updateGroupAttributes: (
_: Readonly<{
avatar?: undefined | ArrayBuffer;
title?: string;
}>
) => void;
onBlockAndDelete: () => void;
onDelete: () => void;
};
@ -56,9 +66,20 @@ export const ConversationDetails: React.ComponentType<Props> = ({
showGroupV2Permissions,
showPendingInvites,
showLightboxForMedia,
updateGroupAttributes,
onBlockAndDelete,
onDelete,
}) => {
const [isEditingGroupAttributes, setIsEditingGroupAttributes] = useState(
false
);
const [
editGroupAttributesRequestState,
setEditGroupAttributesRequestState,
] = useState<EditGroupAttributesRequestState>(
EditGroupAttributesRequestState.Inactive
);
const updateExpireTimer = (event: React.ChangeEvent<HTMLSelectElement>) => {
setDisappearingMessages(parseInt(event.target.value, 10));
};
@ -75,7 +96,14 @@ export const ConversationDetails: React.ComponentType<Props> = ({
return (
<div className="conversation-details-panel">
<ConversationDetailsHeader i18n={i18n} conversation={conversation} />
<ConversationDetailsHeader
canEdit={canEditGroupInfo}
conversation={conversation}
i18n={i18n}
startEditing={() => {
setIsEditingGroupAttributes(true);
}}
/>
{canEditGroupInfo ? (
<PanelSection>
@ -171,6 +199,43 @@ export const ConversationDetails: React.ComponentType<Props> = ({
onDelete={onDelete}
onBlockAndDelete={onBlockAndDelete}
/>
{isEditingGroupAttributes && (
<EditConversationAttributesModal
avatarPath={conversation.avatarPath}
i18n={i18n}
makeRequest={async (
options: Readonly<{
avatar?: undefined | ArrayBuffer;
title?: string;
}>
) => {
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}
/>
)}
</div>
);
};

View File

@ -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> = {}): Props => ({
conversation: createConversation(),
i18n,
canEdit: false,
startEditing: action('startEditing'),
...overrideProps,
});
story.add('Basic', () => {
@ -38,3 +42,9 @@ story.add('Basic', () => {
return <ConversationDetailsHeader {...props} />;
});
story.add('Editable', () => {
const props = createProps({ canEdit: true });
return <ConversationDetailsHeader {...props} />;
});

View File

@ -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<Props> = ({
i18n,
canEdit,
conversation,
i18n,
startEditing,
}) => {
const memberships = conversation.memberships || [];
return (
<div className={bem('root')}>
const contents = (
<>
<Avatar
conversationType="group"
i18n={i18n}
@ -37,6 +41,20 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
])}
</div>
</div>
</div>
</>
);
if (canEdit) {
return (
<button
type="button"
onClick={startEditing}
className={bem('root', 'editable')}
>
{contents}
</button>
);
}
return <div className={bem('root')}>{contents}</div>;
};

View File

@ -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<typeof EditConversationAttributesModal>;
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
avatarPath: undefined,
i18n,
onClose: action('onClose'),
makeRequest: action('onMakeRequest'),
requestState: RequestState.Inactive,
title: 'Bing Bong Group',
...overrideProps,
});
story.add('No avatar, empty title', () => (
<EditConversationAttributesModal {...createProps({ title: '' })} />
));
story.add('Avatar and title', () => (
<EditConversationAttributesModal
{...createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
})}
/>
));
story.add('Request active', () => (
<EditConversationAttributesModal
{...createProps({ requestState: RequestState.Active })}
/>
));
story.add('Has error', () => (
<EditConversationAttributesModal
{...createProps({ requestState: RequestState.InactiveWithError })}
/>
));

View File

@ -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<PropsType> = ({
avatarPath: externalAvatarPath,
i18n,
makeRequest,
onClose,
requestState,
title: externalTitle,
}) => {
const startingTitleRef = useRef<string>(externalTitle);
const startingAvatarPathRef = useRef<undefined | string>(externalAvatarPath);
const [avatar, setAvatar] = useState<undefined | ArrayBuffer>(
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<HTMLFormElement> = event => {
event.preventDefault();
const request: {
avatar?: undefined | ArrayBuffer;
title?: string;
} = {};
if (hasAvatarChanged) {
request.avatar = avatar;
}
if (hasTitleChanged) {
request.title = title;
}
makeRequest(request);
};
return (
<ModalHost onClose={onClose}>
<form
onSubmit={onSubmit}
className="module-EditConversationAttributesModal"
>
<button
aria-label={i18n('close')}
className="module-EditConversationAttributesModal__close-button"
disabled={isRequestActive}
type="button"
onClick={() => {
onClose();
}}
/>
<h1 className="module-EditConversationAttributesModal__header">
{i18n('updateGroupAttributes__title')}
</h1>
<AvatarInput
contextMenuId="edit conversation attributes avatar input"
disabled={isRequestActive}
i18n={i18n}
onChange={newAvatar => {
setAvatar(newAvatar);
setHasAvatarChanged(true);
}}
value={avatar}
variant={AvatarInputVariant.Dark}
/>
<GroupTitleInput
disabled={isRequestActive}
i18n={i18n}
onChangeValue={setTitle}
value={title}
/>
{requestState === RequestState.InactiveWithError && (
<div className="module-EditConversationAttributesModal__error-message">
{i18n('updateGroupAttributes__error-message')}
</div>
)}
<div className="module-EditConversationAttributesModal__button-container">
<Button
disabled={isRequestActive}
onClick={onClose}
variant={ButtonVariant.Secondary}
>
{i18n('cancel')}
</Button>
<Button
type="submit"
variant={ButtonVariant.Primary}
disabled={!canSubmit}
>
{isRequestActive ? (
<Spinner size="20px" svgSize="small" direction="on-avatar" />
) : (
i18n('save')
)}
</Button>
</div>
</form>
</ModalHost>
);
};
async function imagePathToArrayBuffer(src: string): Promise<ArrayBuffer> {
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;
}

View File

@ -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<undefined | GroupChangeClass.Actions> {
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,

View File

@ -1716,6 +1716,27 @@ export class ConversationModel extends window.Backbone.Model<
});
}
async updateGroupAttributesV2(
attributes: Readonly<{
avatar?: undefined | ArrayBuffer;
title?: string;
}>
): Promise<void> {
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<void> {
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 ===

View File

@ -26,6 +26,12 @@ export type SmartConversationDetailsProps = {
selectedMediaItem: MediaItemType,
media: Array<MediaItemType>
) => void;
updateGroupAttributes: (
_: Readonly<{
avatar?: undefined | ArrayBuffer;
title?: string;
}>
) => void;
onBlockAndDelete: () => void;
onDelete: () => void;
};

View File

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

View File

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

View File

@ -0,0 +1,17 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export async function canvasToArrayBuffer(
canvas: HTMLCanvasElement
): Promise<ArrayBuffer> {
const blob: Blob = await new Promise<Blob>((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();
}

6
ts/util/characters.ts Normal file
View File

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

View File

@ -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",

View File

@ -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,
};