New Group administration: update title and avatar
This commit is contained in:
parent
468d491d34
commit
9f5335b854
25 changed files with 806 additions and 61 deletions
|
@ -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"
|
||||
|
|
|
@ -6,9 +6,20 @@
|
|||
@include font-body-1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid $color-gray-15;
|
||||
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;
|
||||
|
|
1
images/icons/v2/compose-solid-24.svg
Normal file
1
images/icons/v2/compose-solid-24.svg
Normal 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 |
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
81
stylesheets/components/EditConversationAttributesModal.scss
Normal file
81
stylesheets/components/EditConversationAttributesModal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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}
|
||||
variant={variant}
|
||||
/>
|
||||
</div>
|
||||
<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} />;
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'),
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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} />;
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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 })}
|
||||
/>
|
||||
));
|
|
@ -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;
|
||||
}
|
93
ts/groups.ts
93
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<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,
|
||||
|
|
|
@ -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 ===
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
19
ts/test-both/util/characters_test.ts
Normal file
19
ts/test-both/util/characters_test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
27
ts/test-electron/util/canvasToArrayBuffer_test.ts
Normal file
27
ts/test-electron/util/canvasToArrayBuffer_test.ts
Normal 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);
|
||||
});
|
||||
});
|
17
ts/util/canvasToArrayBuffer.ts
Normal file
17
ts/util/canvasToArrayBuffer.ts
Normal 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
6
ts/util/characters.ts
Normal 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;
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue