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.", "message": "This group couldnt be created. Check your connection and try again.",
"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": {
"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": { "notSupportedSMS": {
"message": "SMS/MMS messages are not supported.", "message": "SMS/MMS messages are not supported.",
"description": "Label underneath number informing user that SMS is not supported on desktop" "description": "Label underneath number informing user that SMS is not supported on desktop"

View file

@ -6,9 +6,20 @@
@include font-body-1; @include font-body-1;
padding: 8px 12px; padding: 8px 12px;
border-radius: 6px; border-radius: 6px;
border: 2px solid $color-gray-15; border-width: 2px;
background: $color-white; border-style: solid;
color: $color-black;
@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 { &:focus {
outline: none; 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 { .module-conversation-details {
&-header { &-header {
&__root { &__root,
&__root--editable {
align-items: center; align-items: center;
background: none;
border: none;
color: inherit;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: 24px; margin: 0;
outline: inherit;
padding: 0 0 24px 0;
text-align: center; text-align: center;
width: 100%;
}
&__root--editable {
cursor: pointer;
} }
&__title { &__title {
@include font-title-1; @include font-title-1;
padding-top: 12px; align-items: center;
display: flex;
padding-bottom: 8px; padding-bottom: 8px;
padding-top: 12px;
} }
&__subtitle { &__subtitle {
@ -2894,6 +2907,34 @@ button.module-conversation-details__action-button {
color: $color-gray-25; 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 { &__leave-group {

View file

@ -3,12 +3,15 @@
.module-AvatarInput { .module-AvatarInput {
@include button-reset; @include button-reset;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%; width: 100%;
background: none; background: none;
$dark-selector: '#{&}--dark';
&__avatar { &__avatar {
@include button-reset; @include button-reset;
@ -23,6 +26,10 @@
align-items: stretch; align-items: stretch;
background: $color-white; background: $color-white;
@at-root '#{$dark-selector} #{&}' {
background: $ultramarine-ui-light;
}
&::before { &::before {
flex-grow: 1; flex-grow: 1;
content: ''; content: '';
@ -33,6 +40,14 @@
false false
); );
-webkit-mask-size: 24px 24px; -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/ContactPill.scss';
@import './components/ContactPills.scss'; @import './components/ContactPills.scss';
@import './components/ConversationHeader.scss'; @import './components/ConversationHeader.scss';
@import './components/EditConversationAttributesModal.scss';
@import './components/GroupDialog.scss'; @import './components/GroupDialog.scss';
@import './components/GroupTitleInput.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 { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
import { AvatarInput } from './AvatarInput'; import { AvatarInput, AvatarInputVariant } from './AvatarInput';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -22,7 +22,13 @@ const TEST_IMAGE = new Uint8Array(
).map(bytePair => parseInt(bytePair.join(''), 16)) ).map(bytePair => parseInt(bytePair.join(''), 16))
).buffer; ).buffer;
const Wrapper = ({ startValue }: { startValue: undefined | ArrayBuffer }) => { const Wrapper = ({
startValue,
variant,
}: {
startValue: undefined | ArrayBuffer;
variant?: AvatarInputVariant;
}) => {
const [value, setValue] = useState<undefined | ArrayBuffer>(startValue); const [value, setValue] = useState<undefined | ArrayBuffer>(startValue);
const [objectUrl, setObjectUrl] = useState<undefined | string>(); const [objectUrl, setObjectUrl] = useState<undefined | string>();
@ -40,18 +46,13 @@ const Wrapper = ({ startValue }: { startValue: undefined | ArrayBuffer }) => {
return ( return (
<> <>
<div <AvatarInput
style={{ contextMenuId={uuid()}
background: 'rgba(255, 0, 255, 0.1)', i18n={i18n}
}} value={value}
> onChange={setValue}
<AvatarInput variant={variant}
contextMenuId={uuid()} />
i18n={i18n}
value={value}
onChange={setValue}
/>
</div>
<figure> <figure>
<figcaption>Processed image (if it exists)</figcaption> <figcaption>Processed image (if it exists)</figcaption>
{objectUrl && <img src={objectUrl} alt="" />} {objectUrl && <img src={objectUrl} alt="" />}
@ -67,3 +68,7 @@ story.add('No start state', () => {
story.add('Starting with a value', () => { story.add('Starting with a value', () => {
return <Wrapper startValue={TEST_IMAGE} />; 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, MouseEventHandler,
FunctionComponent, FunctionComponent,
} from 'react'; } from 'react';
import classNames from 'classnames';
import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu'; import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu';
import loadImage, { LoadImageOptions } from 'blueimp-load-image'; import loadImage, { LoadImageOptions } from 'blueimp-load-image';
import { noop } from 'lodash'; import { noop } from 'lodash';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
import { Spinner } from './Spinner'; import { Spinner } from './Spinner';
import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer';
type PropsType = { type PropsType = {
// This ID needs to be globally unique across the app. // This ID needs to be globally unique across the app.
@ -23,6 +25,7 @@ type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
onChange: (value: undefined | ArrayBuffer) => unknown; onChange: (value: undefined | ArrayBuffer) => unknown;
value: undefined | ArrayBuffer; value: undefined | ArrayBuffer;
variant?: AvatarInputVariant;
}; };
enum ImageStatus { enum ImageStatus {
@ -31,12 +34,18 @@ enum ImageStatus {
HasImage = 'has-image', HasImage = 'has-image',
} }
export enum AvatarInputVariant {
Light = 'light',
Dark = 'dark',
}
export const AvatarInput: FunctionComponent<PropsType> = ({ export const AvatarInput: FunctionComponent<PropsType> = ({
contextMenuId, contextMenuId,
disabled, disabled,
i18n, i18n,
onChange, onChange,
value, value,
variant = AvatarInputVariant.Light,
}) => { }) => {
const fileInputRef = useRef<null | HTMLInputElement>(null); const fileInputRef = useRef<null | HTMLInputElement>(null);
// Comes from a third-party dependency // Comes from a third-party dependency
@ -136,7 +145,10 @@ export const AvatarInput: FunctionComponent<PropsType> = ({
<button <button
type="button" type="button"
disabled={disabled || isLoading} disabled={disabled || isLoading}
className="module-AvatarInput" className={classNames(
'module-AvatarInput',
`module-AvatarInput--${variant}`
)}
onClick={onClick} onClick={onClick}
> >
<div <div
@ -197,17 +209,5 @@ async function processFile(file: File): Promise<ArrayBuffer> {
throw new Error('Loaded image was not a canvas'); throw new Error('Loaded image was not a canvas');
} }
return (await canvasToBlob(image)).arrayBuffer(); return canvasToArrayBuffer(image);
}
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');
});
} }

View file

@ -16,9 +16,15 @@ type PropsType = {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
variant?: ButtonVariant; variant?: ButtonVariant;
}; } & (
| {
onClick: MouseEventHandler<HTMLButtonElement>;
}
| {
type: 'submit';
}
);
const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([ const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([
[ButtonVariant.Primary, 'module-Button--primary'], [ButtonVariant.Primary, 'module-Button--primary'],
@ -27,16 +33,24 @@ const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([
]); ]);
export const Button = React.forwardRef<HTMLButtonElement, PropsType>( export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
( (props, ref) => {
{ const {
children, children,
className, className,
disabled = false, disabled = false,
onClick,
variant = ButtonVariant.Primary, variant = ButtonVariant.Primary,
}, } = props;
ref
) => { 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); const variantClassName = VARIANT_CLASS_NAMES.get(variant);
assert(variantClassName, '<Button> variant not found'); assert(variantClassName, '<Button> variant not found');
@ -46,7 +60,9 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
ref={ref} 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} {children}
</button> </button>

View file

@ -60,6 +60,7 @@ const createProps = (hasGroupLink = false): Props => ({
showGroupV2Permissions: action('showGroupV2Permissions'), showGroupV2Permissions: action('showGroupV2Permissions'),
showPendingInvites: action('showPendingInvites'), showPendingInvites: action('showPendingInvites'),
showLightboxForMedia: action('showLightboxForMedia'), showLightboxForMedia: action('showLightboxForMedia'),
updateGroupAttributes: action('updateGroupAttributes'),
onBlockAndDelete: action('onBlockAndDelete'), onBlockAndDelete: action('onBlockAndDelete'),
onDelete: action('onDelete'), 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 // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useState } from 'react';
import { ConversationType } from '../../../state/ducks/conversations'; import { ConversationType } from '../../../state/ducks/conversations';
import { import {
@ -18,6 +18,10 @@ import { ConversationDetailsHeader } from './ConversationDetailsHeader';
import { ConversationDetailsIcon } from './ConversationDetailsIcon'; import { ConversationDetailsIcon } from './ConversationDetailsIcon';
import { ConversationDetailsMediaList } from './ConversationDetailsMediaList'; import { ConversationDetailsMediaList } from './ConversationDetailsMediaList';
import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList'; import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList';
import {
EditConversationAttributesModal,
RequestState as EditGroupAttributesRequestState,
} from './EditConversationAttributesModal';
export type StateProps = { export type StateProps = {
canEditGroupInfo: boolean; canEditGroupInfo: boolean;
@ -36,6 +40,12 @@ export type StateProps = {
selectedMediaItem: MediaItemType, selectedMediaItem: MediaItemType,
media: Array<MediaItemType> media: Array<MediaItemType>
) => void; ) => void;
updateGroupAttributes: (
_: Readonly<{
avatar?: undefined | ArrayBuffer;
title?: string;
}>
) => void;
onBlockAndDelete: () => void; onBlockAndDelete: () => void;
onDelete: () => void; onDelete: () => void;
}; };
@ -56,9 +66,20 @@ export const ConversationDetails: React.ComponentType<Props> = ({
showGroupV2Permissions, showGroupV2Permissions,
showPendingInvites, showPendingInvites,
showLightboxForMedia, showLightboxForMedia,
updateGroupAttributes,
onBlockAndDelete, onBlockAndDelete,
onDelete, onDelete,
}) => { }) => {
const [isEditingGroupAttributes, setIsEditingGroupAttributes] = useState(
false
);
const [
editGroupAttributesRequestState,
setEditGroupAttributesRequestState,
] = useState<EditGroupAttributesRequestState>(
EditGroupAttributesRequestState.Inactive
);
const updateExpireTimer = (event: React.ChangeEvent<HTMLSelectElement>) => { const updateExpireTimer = (event: React.ChangeEvent<HTMLSelectElement>) => {
setDisappearingMessages(parseInt(event.target.value, 10)); setDisappearingMessages(parseInt(event.target.value, 10));
}; };
@ -75,7 +96,14 @@ export const ConversationDetails: React.ComponentType<Props> = ({
return ( return (
<div className="conversation-details-panel"> <div className="conversation-details-panel">
<ConversationDetailsHeader i18n={i18n} conversation={conversation} /> <ConversationDetailsHeader
canEdit={canEditGroupInfo}
conversation={conversation}
i18n={i18n}
startEditing={() => {
setIsEditingGroupAttributes(true);
}}
/>
{canEditGroupInfo ? ( {canEditGroupInfo ? (
<PanelSection> <PanelSection>
@ -171,6 +199,43 @@ export const ConversationDetails: React.ComponentType<Props> = ({
onDelete={onDelete} onDelete={onDelete}
onBlockAndDelete={onBlockAndDelete} 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> </div>
); );
}; };

View file

@ -1,9 +1,10 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { number, text } from '@storybook/addon-knobs'; import { number, text } from '@storybook/addon-knobs';
import { setup as setupI18n } from '../../../../js/modules/i18n'; import { setup as setupI18n } from '../../../../js/modules/i18n';
@ -15,7 +16,7 @@ import { ConversationDetailsHeader, Props } from './ConversationDetailsHeader';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
const story = storiesOf( const story = storiesOf(
'Components/Conversation/ConversationDetails/ConversationDetailHeader', 'Components/Conversation/ConversationDetails/ConversationDetailsHeader',
module module
); );
@ -28,9 +29,12 @@ const createConversation = (): ConversationType => ({
memberships: new Array(number('conversation members length', 0)), memberships: new Array(number('conversation members length', 0)),
}); });
const createProps = (): Props => ({ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversation: createConversation(), conversation: createConversation(),
i18n, i18n,
canEdit: false,
startEditing: action('startEditing'),
...overrideProps,
}); });
story.add('Basic', () => { story.add('Basic', () => {
@ -38,3 +42,9 @@ story.add('Basic', () => {
return <ConversationDetailsHeader {...props} />; 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 // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React from 'react';
@ -9,20 +9,24 @@ import { ConversationType } from '../../../state/ducks/conversations';
import { bemGenerator } from './util'; import { bemGenerator } from './util';
export type Props = { export type Props = {
i18n: LocalizerType; canEdit: boolean;
conversation: ConversationType; conversation: ConversationType;
i18n: LocalizerType;
startEditing: () => void;
}; };
const bem = bemGenerator('module-conversation-details-header'); const bem = bemGenerator('module-conversation-details-header');
export const ConversationDetailsHeader: React.ComponentType<Props> = ({ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
i18n, canEdit,
conversation, conversation,
i18n,
startEditing,
}) => { }) => {
const memberships = conversation.memberships || []; const memberships = conversation.memberships || [];
return ( const contents = (
<div className={bem('root')}> <>
<Avatar <Avatar
conversationType="group" conversationType="group"
i18n={i18n} i18n={i18n}
@ -37,6 +41,20 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
])} ])}
</div> </div>
</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( function buildGroupProto(
attributes: Pick< attributes: Pick<
ConversationAttributesType, ConversationAttributesType,
@ -423,10 +433,9 @@ function buildGroupProto(
proto.publicKey = base64ToArrayBuffer(publicParams); proto.publicKey = base64ToArrayBuffer(publicParams);
proto.version = attributes.revision || 0; proto.version = attributes.revision || 0;
const titleBlob = new window.textsecure.protobuf.GroupAttributeBlob(); if (attributes.name) {
titleBlob.title = attributes.name; proto.title = buildGroupTitleBuffer(clientZkGroupCipher, attributes.name);
const titleBlobPlaintext = titleBlob.toArrayBuffer(); }
proto.title = encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext);
if (attributes.avatarUrl) { if (attributes.avatarUrl) {
proto.avatar = attributes.avatarUrl; proto.avatar = attributes.avatarUrl;
@ -533,6 +542,82 @@ function buildGroupProto(
return proto; 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({ export function buildDisappearingMessagesTimerChange({
expireTimer, expireTimer,
group, 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> { async leaveGroupV2(): Promise<void> {
const ourConversationId = window.ConversationController.getOurConversationId(); const ourConversationId = window.ConversationController.getOurConversationId();
@ -4818,6 +4839,10 @@ export class ConversationModel extends window.Backbone.Model<
return false; return false;
} }
if (this.get('left')) {
return false;
}
return ( return (
this.areWeAdmin() || this.areWeAdmin() ||
this.get('accessControl')?.attributes === this.get('accessControl')?.attributes ===

View file

@ -26,6 +26,12 @@ export type SmartConversationDetailsProps = {
selectedMediaItem: MediaItemType, selectedMediaItem: MediaItemType,
media: Array<MediaItemType> media: Array<MediaItemType>
) => void; ) => void;
updateGroupAttributes: (
_: Readonly<{
avatar?: undefined | ArrayBuffer;
title?: string;
}>
) => void;
onBlockAndDelete: () => void; onBlockAndDelete: () => void;
onDelete: () => 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", "rule": "React-useRef",
"path": "ts/components/AvatarInput.js", "path": "ts/components/AvatarInput.js",
"line": " const fileInputRef = react_1.useRef(null);", "line": " const fileInputRef = react_1.useRef(null);",
"lineNumber": 40, "lineNumber": 47,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-03-01T18:34:36.638Z", "updated": "2021-03-01T18:34:36.638Z",
"reasonDetail": "Needed to trigger a click on a 'file' input. Doesn't update the DOM." "reasonDetail": "Needed to trigger a click on a 'file' input. Doesn't update the DOM."
@ -14482,7 +14482,7 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/AvatarInput.js", "path": "ts/components/AvatarInput.js",
"line": " const menuTriggerRef = react_1.useRef(null);", "line": " const menuTriggerRef = react_1.useRef(null);",
"lineNumber": 43, "lineNumber": 50,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-03-01T18:34:36.638Z", "updated": "2021-03-01T18:34:36.638Z",
"reasonDetail": "Used to reference popup menu" "reasonDetail": "Used to reference popup menu"
@ -15045,6 +15045,24 @@
"updated": "2019-07-31T00:19:18.696Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Timeline needs to interact with its child List directly" "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", "rule": "React-createRef",
"path": "ts/components/conversation/media-gallery/MediaGallery.js", "path": "ts/components/conversation/media-gallery/MediaGallery.js",

View file

@ -2897,6 +2897,9 @@ Whisper.ConversationView = Whisper.View.extend({
showGroupV2Permissions: this.showGroupV2Permissions.bind(this), showGroupV2Permissions: this.showGroupV2Permissions.bind(this),
showPendingInvites: this.showPendingInvites.bind(this), showPendingInvites: this.showPendingInvites.bind(this),
showLightboxForMedia: this.showLightboxForMedia.bind(this), showLightboxForMedia: this.showLightboxForMedia.bind(this),
updateGroupAttributes: conversation.updateGroupAttributesV2.bind(
conversation
),
onDelete, onDelete,
onBlockAndDelete, onBlockAndDelete,
}; };