Edit profile
This commit is contained in:
parent
f14c426170
commit
cd35a29638
42 changed files with 2124 additions and 356 deletions
|
@ -5161,6 +5161,10 @@
|
|||
"message": "Add a group photo",
|
||||
"description": "The label for the avatar uploader when no group photo is selected"
|
||||
},
|
||||
"AvatarInput--no-photo-label--profile": {
|
||||
"message": "Add a photo",
|
||||
"description": "The label for the avatar uploader when no profile photo is selected"
|
||||
},
|
||||
"AvatarInput--change-photo-label": {
|
||||
"message": "Change photo",
|
||||
"description": "The label for the avatar uploader when a photo is selected"
|
||||
|
@ -5642,5 +5646,75 @@
|
|||
"MediaQualitySelector--high-quality-description": {
|
||||
"message": "Slower, more data",
|
||||
"description": "Description of high quality selector"
|
||||
},
|
||||
"ProfileEditor--about": {
|
||||
"message": "About",
|
||||
"description": "Default text for about field"
|
||||
},
|
||||
"ProfileEditor--about-placeholder": {
|
||||
"message": "Write something about yourself...",
|
||||
"description": "Placeholder text for about input field"
|
||||
},
|
||||
"ProfileEditor--first-name": {
|
||||
"message": "First Name (Required)",
|
||||
"description": "Placeholder text for first name field"
|
||||
},
|
||||
"ProfileEditor--last-name": {
|
||||
"message": "Last Name (Optional)",
|
||||
"description": "Placeholder text for last name field"
|
||||
},
|
||||
"ProfileEditor--discard": {
|
||||
"message": "Would you like to discard these changes?",
|
||||
"description": "ConfirmationDialog text for discarding changes"
|
||||
},
|
||||
"ProfileEditor--info": {
|
||||
"message": "Your profile is encrypted. Your profile and changes to it will be visible to your contacts and when you start or accept new chats. $learnMore$",
|
||||
"description": "Information shown at the bottom of the profile editor section",
|
||||
"placeholders": {
|
||||
"learnMore": {
|
||||
"content": "$1",
|
||||
"example": "Learn More."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProfileEditor--learnMore": {
|
||||
"message": "Learn More",
|
||||
"description": "Text that links to a support article"
|
||||
},
|
||||
"Bio--speak-freely": {
|
||||
"message": "Speak Freely",
|
||||
"description": "A default bio option"
|
||||
},
|
||||
"Bio--encrypted": {
|
||||
"message": "Encrypted",
|
||||
"description": "A default bio option"
|
||||
},
|
||||
"Bio--free-to-chat": {
|
||||
"message": "Free to chat",
|
||||
"description": "A default bio option"
|
||||
},
|
||||
"Bio--coffee-lover": {
|
||||
"message": "Coffee lover",
|
||||
"description": "A default bio option"
|
||||
},
|
||||
"Bio--taking-break": {
|
||||
"message": "Taking a break",
|
||||
"description": "A default bio option"
|
||||
},
|
||||
"ProfileEditorModal--profile": {
|
||||
"message": "Profile",
|
||||
"description": "Title for profile editing"
|
||||
},
|
||||
"ProfileEditorModal--name": {
|
||||
"message": "Your Name",
|
||||
"description": "Title for editing your name"
|
||||
},
|
||||
"ProfileEditorModal--about": {
|
||||
"message": "About",
|
||||
"description": "Title for about editing"
|
||||
},
|
||||
"ProfileEditorModal--error": {
|
||||
"message": "Your profile could not be updated. Please try again.",
|
||||
"description": "Error message when something goes wrong updating your profile."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8430,6 +8430,14 @@ button.module-image__border-overlay:focus {
|
|||
}
|
||||
}
|
||||
|
||||
&--has-emoji {
|
||||
opacity: 1;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
@include light-theme() {
|
||||
background: $color-gray-05;
|
||||
|
@ -9074,9 +9082,20 @@ button.module-image__border-overlay:focus {
|
|||
}
|
||||
|
||||
.module-avatar-popup__profile {
|
||||
@include button-reset();
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
@include light-theme {
|
||||
background-color: $color-gray-15;
|
||||
}
|
||||
@include dark-theme {
|
||||
background-color: $color-gray-60;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.module-avatar-popup__profile {
|
||||
|
|
125
stylesheets/components/Input.scss
Normal file
125
stylesheets/components/Input.scss
Normal file
|
@ -0,0 +1,125 @@
|
|||
.Input {
|
||||
&__container {
|
||||
@include font-body-1;
|
||||
border-radius: 6px;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
margin: 16px 0;
|
||||
padding: 8px 12px;
|
||||
position: relative;
|
||||
|
||||
@include light-theme {
|
||||
background: $color-white;
|
||||
border-color: $color-gray-15;
|
||||
color: $color-black;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
background: $color-gray-80;
|
||||
border-color: $color-gray-45;
|
||||
color: $color-gray-05;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
@include light-theme {
|
||||
background: $color-gray-02;
|
||||
border-color: $color-gray-05;
|
||||
color: $color-gray-90;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
background: $color-gray-95;
|
||||
border-color: $color-gray-60;
|
||||
color: $color-gray-20;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
|
||||
@include light-theme {
|
||||
border-color: $color-ultramarine;
|
||||
}
|
||||
@include dark-theme {
|
||||
border-color: $color-ultramarine-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 24px;
|
||||
height: 32px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
@include font-body-1;
|
||||
|
||||
background: inherit;
|
||||
border: none;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
|
||||
&--large {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
&--with-icon {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
&:placeholder {
|
||||
color: $color-gray-45;
|
||||
}
|
||||
|
||||
@include light-theme {
|
||||
color: $color-black;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__controls {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 22px;
|
||||
justify-content: flex-end;
|
||||
margin: 8px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&__clear-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
|
||||
@include light-theme {
|
||||
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-60);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-25);
|
||||
}
|
||||
}
|
||||
|
||||
&__remaining-count {
|
||||
@include font-subtitle;
|
||||
color: $color-gray-45;
|
||||
|
||||
&--large {
|
||||
bottom: 0;
|
||||
margin: 12px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
84
stylesheets/components/ProfileEditor.scss
Normal file
84
stylesheets/components/ProfileEditor.scss
Normal file
|
@ -0,0 +1,84 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.ProfileEditor {
|
||||
padding-bottom: 48px;
|
||||
position: relative;
|
||||
|
||||
&__buttons {
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
button {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
&--container {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: 24px;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
-webkit-mask-size: 100%;
|
||||
content: '';
|
||||
display: block;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
@include light-theme {
|
||||
background-color: $color-gray-75;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
background-color: $color-gray-15;
|
||||
}
|
||||
}
|
||||
|
||||
&--name {
|
||||
&::after {
|
||||
-webkit-mask: url(../images/icons/v2/profile-outline-20.svg) no-repeat
|
||||
center;
|
||||
}
|
||||
}
|
||||
|
||||
&--bio {
|
||||
&::after {
|
||||
-webkit-mask: url(../images/icons/v2/compose-outline-24.svg) no-repeat
|
||||
center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__about-input {
|
||||
&__icon {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
&__input--with-icon {
|
||||
padding-left: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
border-color: $color-gray-15;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
&__info {
|
||||
@include font-body-2;
|
||||
color: $color-gray-60;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
|
@ -50,16 +50,18 @@
|
|||
@import './components/GroupDescription.scss';
|
||||
@import './components/GroupDialog.scss';
|
||||
@import './components/GroupInput.scss';
|
||||
@import './components/Input.scss';
|
||||
@import './components/MediaQualitySelector.scss';
|
||||
@import './components/MessageAudio.scss';
|
||||
@import './components/Modal.scss';
|
||||
@import './components/ProfileEditor.scss';
|
||||
@import './components/SafetyNumberChangeDialog.scss';
|
||||
@import './components/SafetyNumberViewer.scss';
|
||||
@import './components/SearchInput.scss';
|
||||
@import './components/SearchResultsLoadingFakeHeader.scss';
|
||||
@import './components/SearchResultsLoadingFakeRow.scss';
|
||||
@import './components/Select.scss';
|
||||
@import './components/Slider.scss';
|
||||
@import './components/Tabs.scss';
|
||||
@import './components/Select.scss';
|
||||
@import './components/TimelineWarning.scss';
|
||||
@import './components/TimelineWarnings.scss';
|
||||
|
|
|
@ -18,12 +18,13 @@ import { LocalizerType } from '../types/Util';
|
|||
import { Spinner } from './Spinner';
|
||||
import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer';
|
||||
|
||||
type PropsType = {
|
||||
export type PropsType = {
|
||||
// This ID needs to be globally unique across the app.
|
||||
contextMenuId: string;
|
||||
disabled?: boolean;
|
||||
i18n: LocalizerType;
|
||||
onChange: (value: undefined | ArrayBuffer) => unknown;
|
||||
type?: AvatarInputType;
|
||||
value: undefined | ArrayBuffer;
|
||||
variant?: AvatarInputVariant;
|
||||
};
|
||||
|
@ -34,6 +35,11 @@ enum ImageStatus {
|
|||
HasImage = 'has-image',
|
||||
}
|
||||
|
||||
export enum AvatarInputType {
|
||||
Profile = 'Profile',
|
||||
Group = 'Group',
|
||||
}
|
||||
|
||||
export enum AvatarInputVariant {
|
||||
Light = 'light',
|
||||
Dark = 'dark',
|
||||
|
@ -44,6 +50,7 @@ export const AvatarInput: FunctionComponent<PropsType> = ({
|
|||
disabled,
|
||||
i18n,
|
||||
onChange,
|
||||
type,
|
||||
value,
|
||||
variant = AvatarInputVariant.Light,
|
||||
}) => {
|
||||
|
@ -96,9 +103,14 @@ export const AvatarInput: FunctionComponent<PropsType> = ({
|
|||
};
|
||||
}, [processingFile, onChange]);
|
||||
|
||||
const buttonLabel = value
|
||||
? i18n('AvatarInput--change-photo-label')
|
||||
: i18n('AvatarInput--no-photo-label--group');
|
||||
let buttonLabel = i18n('AvatarInput--change-photo-label');
|
||||
if (!value) {
|
||||
if (type === AvatarInputType.Profile) {
|
||||
buttonLabel = i18n('AvatarInput--no-photo-label--profile');
|
||||
} else {
|
||||
buttonLabel = i18n('AvatarInput--no-photo-label--group');
|
||||
}
|
||||
}
|
||||
|
||||
const startUpload = () => {
|
||||
const fileInput = fileInputRef.current;
|
||||
|
|
43
ts/components/AvatarInputContainer.stories.tsx
Normal file
43
ts/components/AvatarInputContainer.stories.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { AvatarInputContainer } from './AvatarInputContainer';
|
||||
import { AvatarInputType } from './AvatarInput';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf('Components/AvatarInputContainer', module);
|
||||
|
||||
story.add('No photo (group)', () => (
|
||||
<AvatarInputContainer
|
||||
contextMenuId={uuid()}
|
||||
i18n={i18n}
|
||||
onAvatarChanged={noop}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('No photo (profile)', () => (
|
||||
<AvatarInputContainer
|
||||
contextMenuId={uuid()}
|
||||
i18n={i18n}
|
||||
onAvatarChanged={noop}
|
||||
type={AvatarInputType.Profile}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Has photo', () => (
|
||||
<AvatarInputContainer
|
||||
avatarPath="/fixtures/kitten-3-64-64.jpg"
|
||||
contextMenuId={uuid()}
|
||||
i18n={i18n}
|
||||
onAvatarChanged={noop}
|
||||
/>
|
||||
));
|
86
ts/components/AvatarInputContainer.tsx
Normal file
86
ts/components/AvatarInputContainer.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import { AvatarInput, PropsType as AvatarInputPropsType } from './AvatarInput';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { imagePathToArrayBuffer } from '../util/imagePathToArrayBuffer';
|
||||
|
||||
type PropsType = {
|
||||
avatarPath?: string;
|
||||
i18n: LocalizerType;
|
||||
onAvatarChanged: (avatar: ArrayBuffer | undefined) => unknown;
|
||||
onAvatarLoaded?: (avatar: ArrayBuffer | undefined) => unknown;
|
||||
} & Pick<
|
||||
AvatarInputPropsType,
|
||||
'contextMenuId' | 'disabled' | 'type' | 'variant'
|
||||
>;
|
||||
|
||||
const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
|
||||
|
||||
export const AvatarInputContainer = ({
|
||||
avatarPath,
|
||||
contextMenuId,
|
||||
disabled,
|
||||
i18n,
|
||||
onAvatarChanged,
|
||||
onAvatarLoaded,
|
||||
type,
|
||||
variant,
|
||||
}: PropsType): JSX.Element => {
|
||||
const startingAvatarPathRef = useRef<undefined | string>(avatarPath);
|
||||
|
||||
const [avatar, setAvatar] = useState<undefined | ArrayBuffer>(
|
||||
avatarPath ? TEMPORARY_AVATAR_VALUE : undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const startingAvatarPath = startingAvatarPathRef.current;
|
||||
if (!startingAvatarPath) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
let shouldCancel = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const buffer = await imagePathToArrayBuffer(startingAvatarPath);
|
||||
if (shouldCancel) {
|
||||
return;
|
||||
}
|
||||
setAvatar(buffer);
|
||||
if (onAvatarLoaded) {
|
||||
onAvatarLoaded(buffer);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`Failed to convert image URL to array buffer. Error message: ${
|
||||
err && err.message
|
||||
}`
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
shouldCancel = true;
|
||||
};
|
||||
}, [onAvatarLoaded]);
|
||||
|
||||
return (
|
||||
<AvatarInput
|
||||
contextMenuId={contextMenuId}
|
||||
disabled={disabled}
|
||||
i18n={i18n}
|
||||
onChange={newAvatar => {
|
||||
setAvatar(newAvatar);
|
||||
onAvatarChanged(newAvatar);
|
||||
}}
|
||||
type={type}
|
||||
value={avatar}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -40,6 +40,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
isMe: true,
|
||||
name: text('name', overrideProps.name || ''),
|
||||
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
|
||||
onEditProfile: action('onEditProfile'),
|
||||
onClick: action('onClick'),
|
||||
onSetChatColor: action('onSetChatColor'),
|
||||
onViewArchive: action('onViewArchive'),
|
||||
|
|
|
@ -12,6 +12,7 @@ import { LocalizerType } from '../types/Util';
|
|||
export type Props = {
|
||||
readonly i18n: LocalizerType;
|
||||
|
||||
onEditProfile: () => unknown;
|
||||
onSetChatColor: () => unknown;
|
||||
onViewPreferences: () => unknown;
|
||||
onViewArchive: () => unknown;
|
||||
|
@ -29,6 +30,7 @@ export const AvatarPopup = (props: Props): JSX.Element => {
|
|||
profileName,
|
||||
phoneNumber,
|
||||
title,
|
||||
onEditProfile,
|
||||
onSetChatColor,
|
||||
onViewPreferences,
|
||||
onViewArchive,
|
||||
|
@ -44,7 +46,12 @@ export const AvatarPopup = (props: Props): JSX.Element => {
|
|||
|
||||
return (
|
||||
<div style={style} className="module-avatar-popup">
|
||||
<div className="module-avatar-popup__profile">
|
||||
<button
|
||||
className="module-avatar-popup__profile"
|
||||
onClick={onEditProfile}
|
||||
ref={focusRef}
|
||||
type="button"
|
||||
>
|
||||
<Avatar {...props} size={52} />
|
||||
<div className="module-avatar-popup__profile__text">
|
||||
<div className="module-avatar-popup__profile__name">
|
||||
|
@ -56,11 +63,10 @@ export const AvatarPopup = (props: Props): JSX.Element => {
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<hr className="module-avatar-popup__divider" />
|
||||
<button
|
||||
type="button"
|
||||
ref={focusRef}
|
||||
className="module-avatar-popup__item"
|
||||
onClick={onViewPreferences}
|
||||
>
|
||||
|
|
|
@ -309,7 +309,6 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
|||
/>
|
||||
<div className="module-ForwardMessageModal__emoji">
|
||||
<EmojiButton
|
||||
doSend={noop}
|
||||
i18n={i18n}
|
||||
onClose={focusTextEditInput}
|
||||
onPickEmoji={insertEmoji}
|
||||
|
|
|
@ -7,16 +7,28 @@ import { LocalizerType } from '../types/Util';
|
|||
|
||||
type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
|
||||
// ChatColorPicker
|
||||
isChatColorEditorVisible: boolean;
|
||||
renderChatColorPicker: () => JSX.Element;
|
||||
toggleChatColorEditor: () => unknown;
|
||||
|
||||
// ProfileEditor
|
||||
isProfileEditorVisible: boolean;
|
||||
renderProfileEditor: () => JSX.Element;
|
||||
};
|
||||
|
||||
export const GlobalModalContainer = ({
|
||||
i18n,
|
||||
|
||||
// ChatColorPicker
|
||||
isChatColorEditorVisible,
|
||||
renderChatColorPicker,
|
||||
toggleChatColorEditor,
|
||||
|
||||
// ProfileEditor
|
||||
isProfileEditorVisible,
|
||||
renderProfileEditor,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
if (isChatColorEditorVisible) {
|
||||
return (
|
||||
|
@ -33,5 +45,9 @@ export const GlobalModalContainer = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (isProfileEditorVisible) {
|
||||
return renderProfileEditor();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
44
ts/components/GroupDescriptionInput.stories.tsx
Normal file
44
ts/components/GroupDescriptionInput.stories.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { GroupDescriptionInput } from './GroupDescriptionInput';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf('Components/GroupDescriptionInput', module);
|
||||
|
||||
const Wrapper = ({
|
||||
disabled,
|
||||
startingValue = '',
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
startingValue?: string;
|
||||
}) => {
|
||||
const [value, setValue] = useState(startingValue);
|
||||
|
||||
return (
|
||||
<GroupDescriptionInput
|
||||
disabled={disabled}
|
||||
i18n={i18n}
|
||||
onChangeValue={setValue}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
story.add('Default', () => <Wrapper />);
|
||||
|
||||
story.add('Disabled', () => (
|
||||
<>
|
||||
<Wrapper disabled />
|
||||
<br />
|
||||
<Wrapper disabled startingValue="Has a value" />
|
||||
</>
|
||||
));
|
|
@ -1,21 +1,10 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, {
|
||||
ClipboardEvent,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { Input } from './Input';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { multiRef } from '../util/multiRef';
|
||||
import * as grapheme from '../util/grapheme';
|
||||
|
||||
const MAX_GRAPHEME_COUNT = 256;
|
||||
const SHOW_REMAINING_COUNT = 150;
|
||||
|
||||
type PropsType = {
|
||||
disabled?: boolean;
|
||||
|
@ -24,128 +13,20 @@ type PropsType = {
|
|||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Group titles must have fewer than MAX_GRAPHEME_COUNT glyphs. Ideally, we'd use the
|
||||
* `maxLength` property on inputs, but that doesn't account for glyphs that are more than
|
||||
* one UTF-16 code units. For example: `'💩💩'.length === 4`.
|
||||
*
|
||||
* This component effectively implements a "max grapheme length" on an input.
|
||||
*
|
||||
* At a high level, this component handles two methods of input:
|
||||
*
|
||||
* - `onChange`. *Before* the value is changed (in `onKeyDown`), we save the value and the
|
||||
* cursor position. Then, in `onChange`, we see if the new value is too long. If it is,
|
||||
* we revert the value and selection. Otherwise, we fire `onChangeValue`.
|
||||
*
|
||||
* - `onPaste`. If you're pasting something that will fit, we fall back to normal browser
|
||||
* behavior, which calls `onChange`. If you're pasting something that won't fit, it's a
|
||||
* noop.
|
||||
*/
|
||||
export const GroupDescriptionInput = forwardRef<HTMLTextAreaElement, PropsType>(
|
||||
export const GroupDescriptionInput = forwardRef<HTMLInputElement, PropsType>(
|
||||
({ i18n, disabled = false, onChangeValue, value }, ref) => {
|
||||
const innerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const valueOnKeydownRef = useRef<string>(value);
|
||||
const selectionStartOnKeydownRef = useRef<number>(value.length);
|
||||
const [isLarge, setIsLarge] = useState(false);
|
||||
|
||||
function maybeSetLarge() {
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputEl.scrollHeight > inputEl.clientHeight) {
|
||||
setIsLarge(true);
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = () => {
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
valueOnKeydownRef.current = inputEl.value;
|
||||
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
|
||||
};
|
||||
|
||||
const onChange = () => {
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = inputEl.value;
|
||||
const newGraphemeCount = grapheme.count(newValue);
|
||||
|
||||
if (newGraphemeCount <= MAX_GRAPHEME_COUNT) {
|
||||
onChangeValue(newValue);
|
||||
} else {
|
||||
inputEl.value = valueOnKeydownRef.current;
|
||||
inputEl.selectionStart = selectionStartOnKeydownRef.current;
|
||||
inputEl.selectionEnd = selectionStartOnKeydownRef.current;
|
||||
}
|
||||
|
||||
maybeSetLarge();
|
||||
};
|
||||
|
||||
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionStart = inputEl.selectionStart || 0;
|
||||
const selectionEnd = inputEl.selectionEnd || inputEl.selectionStart || 0;
|
||||
const textBeforeSelection = value.slice(0, selectionStart);
|
||||
const textAfterSelection = value.slice(selectionEnd);
|
||||
|
||||
const pastedText = event.clipboardData.getData('Text');
|
||||
|
||||
const newGraphemeCount =
|
||||
grapheme.count(textBeforeSelection) +
|
||||
grapheme.count(pastedText) +
|
||||
grapheme.count(textAfterSelection);
|
||||
|
||||
if (newGraphemeCount > MAX_GRAPHEME_COUNT) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
maybeSetLarge();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
maybeSetLarge();
|
||||
}, []);
|
||||
|
||||
const graphemeCount = grapheme.count(value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="module-GroupInput--container module-GroupInput__description--container">
|
||||
<textarea
|
||||
className={classNames({
|
||||
'module-GroupInput': true,
|
||||
'module-GroupInput__description': true,
|
||||
'module-GroupInput__description--large': isLarge,
|
||||
})}
|
||||
<Input
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
placeholder={i18n(
|
||||
'setGroupMetadata__group-description-placeholder'
|
||||
)}
|
||||
ref={multiRef<HTMLTextAreaElement>(ref, innerRef)}
|
||||
expandable
|
||||
i18n={i18n}
|
||||
onChange={onChangeValue}
|
||||
placeholder={i18n('setGroupMetadata__group-description-placeholder')}
|
||||
maxGraphemeCount={256}
|
||||
ref={ref}
|
||||
value={value}
|
||||
whenToShowRemainingCount={150}
|
||||
/>
|
||||
{graphemeCount >= SHOW_REMAINING_COUNT && (
|
||||
<div className="module-GroupInput__description--remaining">
|
||||
{MAX_GRAPHEME_COUNT - graphemeCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { forwardRef, useRef, ClipboardEvent } from 'react';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { Input } from './Input';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { multiRef } from '../util/multiRef';
|
||||
import * as grapheme from '../util/grapheme';
|
||||
|
||||
const MAX_GRAPHEME_COUNT = 32;
|
||||
|
||||
type PropsType = {
|
||||
disabled?: boolean;
|
||||
|
@ -16,87 +13,18 @@ type PropsType = {
|
|||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Group titles must have fewer than MAX_GRAPHEME_COUNT glyphs. Ideally, we'd use the
|
||||
* `maxLength` property on inputs, but that doesn't account for glyphs that are more than
|
||||
* one UTF-16 code units. For example: `'💩💩'.length === 4`.
|
||||
*
|
||||
* This component effectively implements a "max grapheme length" on an input.
|
||||
*
|
||||
* At a high level, this component handles two methods of input:
|
||||
*
|
||||
* - `onChange`. *Before* the value is changed (in `onKeyDown`), we save the value and the
|
||||
* cursor position. Then, in `onChange`, we see if the new value is too long. If it is,
|
||||
* we revert the value and selection. Otherwise, we fire `onChangeValue`.
|
||||
*
|
||||
* - `onPaste`. If you're pasting something that will fit, we fall back to normal browser
|
||||
* behavior, which calls `onChange`. If you're pasting something that won't fit, it's a
|
||||
* noop.
|
||||
*/
|
||||
export const GroupTitleInput = forwardRef<HTMLInputElement, PropsType>(
|
||||
({ i18n, disabled = false, onChangeValue, value }, ref) => {
|
||||
const innerRef = useRef<HTMLInputElement | null>(null);
|
||||
const valueOnKeydownRef = useRef<string>(value);
|
||||
const selectionStartOnKeydownRef = useRef<number>(value.length);
|
||||
|
||||
return (
|
||||
<div className="module-GroupInput--container">
|
||||
<input
|
||||
<Input
|
||||
disabled={disabled}
|
||||
className="module-GroupInput"
|
||||
onKeyDown={() => {
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
valueOnKeydownRef.current = inputEl.value;
|
||||
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
|
||||
}}
|
||||
onChange={() => {
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = inputEl.value;
|
||||
if (grapheme.count(newValue) <= MAX_GRAPHEME_COUNT) {
|
||||
onChangeValue(newValue);
|
||||
} else {
|
||||
inputEl.value = valueOnKeydownRef.current;
|
||||
inputEl.selectionStart = selectionStartOnKeydownRef.current;
|
||||
inputEl.selectionEnd = selectionStartOnKeydownRef.current;
|
||||
}
|
||||
}}
|
||||
onPaste={(event: ClipboardEvent<HTMLInputElement>) => {
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionStart = inputEl.selectionStart || 0;
|
||||
const selectionEnd =
|
||||
inputEl.selectionEnd || inputEl.selectionStart || 0;
|
||||
const textBeforeSelection = value.slice(0, selectionStart);
|
||||
const textAfterSelection = value.slice(selectionEnd);
|
||||
|
||||
const pastedText = event.clipboardData.getData('Text');
|
||||
|
||||
const newGraphemeCount =
|
||||
grapheme.count(textBeforeSelection) +
|
||||
grapheme.count(pastedText) +
|
||||
grapheme.count(textAfterSelection);
|
||||
|
||||
if (newGraphemeCount > MAX_GRAPHEME_COUNT) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
i18n={i18n}
|
||||
onChange={onChangeValue}
|
||||
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
|
||||
ref={multiRef<HTMLInputElement>(ref, innerRef)}
|
||||
type="text"
|
||||
maxGraphemeCount={32}
|
||||
ref={ref}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
93
ts/components/Input.stories.tsx
Normal file
93
ts/components/Input.stories.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { text } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { Input, PropsType } from './Input';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const stories = storiesOf('Components/Input', module);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
disabled: Boolean(overrideProps.disabled),
|
||||
expandable: Boolean(overrideProps.expandable),
|
||||
hasClearButton: Boolean(overrideProps.hasClearButton),
|
||||
i18n,
|
||||
icon: overrideProps.icon,
|
||||
maxGraphemeCount: overrideProps.maxGraphemeCount,
|
||||
onChange: action('onChange'),
|
||||
placeholder: text(
|
||||
'placeholder',
|
||||
overrideProps.placeholder || 'Enter some text here'
|
||||
),
|
||||
value: text('value', overrideProps.value || ''),
|
||||
whenToShowRemainingCount: overrideProps.whenToShowRemainingCount,
|
||||
});
|
||||
|
||||
function Controller(props: PropsType): JSX.Element {
|
||||
const { value: initialValue } = props;
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
return <Input {...props} onChange={setValue} value={value} />;
|
||||
}
|
||||
|
||||
stories.add('Simple', () => <Controller {...createProps()} />);
|
||||
|
||||
stories.add('hasClearButton', () => (
|
||||
<Controller
|
||||
{...createProps({
|
||||
hasClearButton: true,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('character count', () => (
|
||||
<Controller
|
||||
{...createProps({
|
||||
maxGraphemeCount: 10,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('character count (customizable show)', () => (
|
||||
<Controller
|
||||
{...createProps({
|
||||
maxGraphemeCount: 64,
|
||||
whenToShowRemainingCount: 32,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('expandable', () => (
|
||||
<Controller
|
||||
{...createProps({
|
||||
expandable: true,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('expandable w/count', () => (
|
||||
<Controller
|
||||
{...createProps({
|
||||
expandable: true,
|
||||
hasClearButton: true,
|
||||
maxGraphemeCount: 140,
|
||||
whenToShowRemainingCount: 0,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('disabled', () => (
|
||||
<Controller
|
||||
{...createProps({
|
||||
disabled: true,
|
||||
})}
|
||||
/>
|
||||
));
|
223
ts/components/Input.tsx
Normal file
223
ts/components/Input.tsx
Normal file
|
@ -0,0 +1,223 @@
|
|||
import React, {
|
||||
ClipboardEvent,
|
||||
ReactNode,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import * as grapheme from '../util/grapheme';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { getClassNamesFor } from '../util/getClassNamesFor';
|
||||
import { multiRef } from '../util/multiRef';
|
||||
|
||||
export type PropsType = {
|
||||
disabled?: boolean;
|
||||
expandable?: boolean;
|
||||
hasClearButton?: boolean;
|
||||
i18n: LocalizerType;
|
||||
icon?: ReactNode;
|
||||
maxGraphemeCount?: number;
|
||||
moduleClassName?: string;
|
||||
onChange: (value: string) => unknown;
|
||||
placeholder: string;
|
||||
value?: string;
|
||||
whenToShowRemainingCount?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Some inputs must have fewer than maxGraphemeCount glyphs. Ideally, we'd use the
|
||||
* `maxLength` property on inputs, but that doesn't account for glyphs that are more than
|
||||
* one UTF-16 code units. For example: `'💩💩'.length === 4`.
|
||||
*
|
||||
* This component effectively implements a "max grapheme length" on an input.
|
||||
*
|
||||
* At a high level, this component handles two methods of input:
|
||||
*
|
||||
* - `onChange`. *Before* the value is changed (in `onKeyDown`), we save the value and the
|
||||
* cursor position. Then, in `onChange`, we see if the new value is too long. If it is,
|
||||
* we revert the value and selection. Otherwise, we fire `onChangeValue`.
|
||||
*
|
||||
* - `onPaste`. If you're pasting something that will fit, we fall back to normal browser
|
||||
* behavior, which calls `onChange`. If you're pasting something that won't fit, it's a
|
||||
* noop.
|
||||
*/
|
||||
export const Input = forwardRef<
|
||||
HTMLInputElement | HTMLTextAreaElement,
|
||||
PropsType
|
||||
>(
|
||||
(
|
||||
{
|
||||
disabled,
|
||||
expandable,
|
||||
hasClearButton,
|
||||
i18n,
|
||||
icon,
|
||||
maxGraphemeCount = 0,
|
||||
moduleClassName,
|
||||
onChange,
|
||||
placeholder,
|
||||
value = '',
|
||||
whenToShowRemainingCount = Infinity,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const innerRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(
|
||||
null
|
||||
);
|
||||
const valueOnKeydownRef = useRef<string>(value);
|
||||
const selectionStartOnKeydownRef = useRef<number>(value.length);
|
||||
const [isLarge, setIsLarge] = useState(false);
|
||||
|
||||
const maybeSetLarge = useCallback(() => {
|
||||
if (!expandable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
inputEl.scrollHeight > inputEl.clientHeight ||
|
||||
inputEl.scrollWidth > inputEl.clientWidth
|
||||
) {
|
||||
setIsLarge(true);
|
||||
}
|
||||
}, [expandable]);
|
||||
|
||||
const handleKeyDown = useCallback(() => {
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
valueOnKeydownRef.current = inputEl.value;
|
||||
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = inputEl.value;
|
||||
|
||||
const newGraphemeCount = maxGraphemeCount ? grapheme.count(newValue) : 0;
|
||||
|
||||
if (newGraphemeCount <= maxGraphemeCount) {
|
||||
onChange(newValue);
|
||||
} else {
|
||||
inputEl.value = valueOnKeydownRef.current;
|
||||
inputEl.selectionStart = selectionStartOnKeydownRef.current;
|
||||
inputEl.selectionEnd = selectionStartOnKeydownRef.current;
|
||||
}
|
||||
|
||||
maybeSetLarge();
|
||||
}, [maxGraphemeCount, maybeSetLarge, onChange]);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(event: ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const inputEl = innerRef.current;
|
||||
if (!inputEl || !maxGraphemeCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionStart = inputEl.selectionStart || 0;
|
||||
const selectionEnd =
|
||||
inputEl.selectionEnd || inputEl.selectionStart || 0;
|
||||
const textBeforeSelection = value.slice(0, selectionStart);
|
||||
const textAfterSelection = value.slice(selectionEnd);
|
||||
|
||||
const pastedText = event.clipboardData.getData('Text');
|
||||
|
||||
const newGraphemeCount =
|
||||
grapheme.count(textBeforeSelection) +
|
||||
grapheme.count(pastedText) +
|
||||
grapheme.count(textAfterSelection);
|
||||
|
||||
if (newGraphemeCount > maxGraphemeCount) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
maybeSetLarge();
|
||||
},
|
||||
[maxGraphemeCount, maybeSetLarge, value]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
maybeSetLarge();
|
||||
}, [maybeSetLarge]);
|
||||
|
||||
const graphemeCount = maxGraphemeCount ? grapheme.count(value) : -1;
|
||||
const getClassName = getClassNamesFor('Input', moduleClassName);
|
||||
|
||||
const inputProps = {
|
||||
className: classNames(
|
||||
getClassName('__input'),
|
||||
icon && getClassName('__input--with-icon'),
|
||||
isLarge && getClassName('__input--large')
|
||||
),
|
||||
disabled: Boolean(disabled),
|
||||
onChange: handleChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
onPaste: handlePaste,
|
||||
placeholder,
|
||||
ref: multiRef<HTMLInputElement | HTMLTextAreaElement | null>(
|
||||
ref,
|
||||
innerRef
|
||||
),
|
||||
type: 'text',
|
||||
value,
|
||||
};
|
||||
|
||||
const clearButtonElement =
|
||||
hasClearButton && value ? (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className={getClassName('__clear-icon')}
|
||||
onClick={() => onChange('')}
|
||||
type="button"
|
||||
aria-label={i18n('cancel')}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const graphemeCountElement = graphemeCount >= whenToShowRemainingCount && (
|
||||
<div className={getClassName('__remaining-count')}>
|
||||
{maxGraphemeCount - graphemeCount}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
getClassName('__container'),
|
||||
disabled && getClassName('__container--disabled')
|
||||
)}
|
||||
>
|
||||
{icon ? <div className={getClassName('__icon')}>{icon}</div> : null}
|
||||
{expandable ? <textarea {...inputProps} /> : <input {...inputProps} />}
|
||||
{isLarge ? (
|
||||
<>
|
||||
<div className={getClassName('__controls')}>
|
||||
{clearButtonElement}
|
||||
</div>
|
||||
<div className={getClassName('__remaining-count--large')}>
|
||||
{graphemeCountElement}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={getClassName('__controls')}>
|
||||
{graphemeCountElement}
|
||||
{clearButtonElement}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -59,6 +59,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
showArchivedConversations: action('showArchivedConversations'),
|
||||
startComposing: action('startComposing'),
|
||||
toggleChatColorEditor: action('toggleChatColorEditor'),
|
||||
toggleProfileEditor: action('toggleProfileEditor'),
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
|
|
|
@ -65,6 +65,7 @@ export type PropsType = {
|
|||
showArchivedConversations: () => void;
|
||||
startComposing: () => void;
|
||||
toggleChatColorEditor: () => void;
|
||||
toggleProfileEditor: () => void;
|
||||
};
|
||||
|
||||
type StateType = {
|
||||
|
@ -353,6 +354,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
searchTerm,
|
||||
showArchivedConversations,
|
||||
toggleChatColorEditor,
|
||||
toggleProfileEditor,
|
||||
} = this.props;
|
||||
const { showingAvatarPopup, popperRoot } = this.state;
|
||||
|
||||
|
@ -410,6 +412,10 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
size={28}
|
||||
// See the comment above about `sharedGroupNames`.
|
||||
sharedGroupNames={[]}
|
||||
onEditProfile={() => {
|
||||
toggleProfileEditor();
|
||||
this.hideAvatarPopup();
|
||||
}}
|
||||
onSetChatColor={() => {
|
||||
toggleChatColorEditor();
|
||||
this.hideAvatarPopup();
|
||||
|
|
68
ts/components/ProfileEditor.stories.tsx
Normal file
68
ts/components/ProfileEditor.stories.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { text } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { ProfileEditor, PropsType } from './ProfileEditor';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import {
|
||||
getFirstName,
|
||||
getLastName,
|
||||
} from '../test-both/helpers/getDefaultConversation';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const stories = storiesOf('Components/ProfileEditor', module);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
aboutEmoji: overrideProps.aboutEmoji,
|
||||
aboutText: text('about', overrideProps.aboutText || ''),
|
||||
avatarPath: overrideProps.avatarPath,
|
||||
familyName: overrideProps.familyName,
|
||||
firstName: text('firstName', overrideProps.firstName || getFirstName()),
|
||||
i18n,
|
||||
onEditStateChanged: action('onEditStateChanged'),
|
||||
onProfileChanged: action('onProfileChanged'),
|
||||
onSetSkinTone: overrideProps.onSetSkinTone || action('onSetSkinTone'),
|
||||
recentEmojis: [],
|
||||
skinTone: overrideProps.skinTone || 0,
|
||||
});
|
||||
|
||||
stories.add('Full Set', () => {
|
||||
const [skinTone, setSkinTone] = useState(0);
|
||||
|
||||
return (
|
||||
<ProfileEditor
|
||||
{...createProps({
|
||||
aboutEmoji: '🙏',
|
||||
aboutText: 'Live. Laugh. Love',
|
||||
avatarPath: '/fixtures/kitten-3-64-64.jpg',
|
||||
onSetSkinTone: setSkinTone,
|
||||
familyName: getLastName(),
|
||||
skinTone,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
stories.add('with Full Name', () => (
|
||||
<ProfileEditor
|
||||
{...createProps({
|
||||
familyName: getLastName(),
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
stories.add('with Custom About', () => (
|
||||
<ProfileEditor
|
||||
{...createProps({
|
||||
aboutEmoji: '🙏',
|
||||
aboutText: 'Live. Laugh. Love',
|
||||
})}
|
||||
/>
|
||||
));
|
431
ts/components/ProfileEditor.tsx
Normal file
431
ts/components/ProfileEditor.tsx
Normal file
|
@ -0,0 +1,431 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as grapheme from '../util/grapheme';
|
||||
|
||||
import { AvatarInputContainer } from './AvatarInputContainer';
|
||||
import { AvatarInputType } from './AvatarInput';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { Emoji } from './emoji/Emoji';
|
||||
import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton';
|
||||
import { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||
import { Input } from './Input';
|
||||
import { Intl } from './Intl';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { PanelRow } from './conversation/conversation-details/PanelRow';
|
||||
import { ProfileDataType } from '../state/ducks/conversations';
|
||||
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
export enum EditState {
|
||||
None = 'None',
|
||||
ProfileName = 'ProfileName',
|
||||
Bio = 'Bio',
|
||||
}
|
||||
|
||||
type PropsExternalType = {
|
||||
onEditStateChanged: (editState: EditState) => unknown;
|
||||
onProfileChanged: (
|
||||
profileData: ProfileDataType,
|
||||
avatarData?: ArrayBuffer
|
||||
) => unknown;
|
||||
};
|
||||
|
||||
export type PropsDataType = {
|
||||
aboutEmoji?: string;
|
||||
aboutText?: string;
|
||||
avatarPath?: string;
|
||||
familyName?: string;
|
||||
firstName: string;
|
||||
i18n: LocalizerType;
|
||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
||||
|
||||
type PropsActionType = {
|
||||
onSetSkinTone: (tone: number) => unknown;
|
||||
};
|
||||
|
||||
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
|
||||
|
||||
type DefaultBio = {
|
||||
i18nLabel: string;
|
||||
shortName: string;
|
||||
};
|
||||
|
||||
const DEFAULT_BIOS: Array<DefaultBio> = [
|
||||
{
|
||||
i18nLabel: 'Bio--speak-freely',
|
||||
shortName: 'wave',
|
||||
},
|
||||
{
|
||||
i18nLabel: 'Bio--encrypted',
|
||||
shortName: 'zipper_mouth_face',
|
||||
},
|
||||
{
|
||||
i18nLabel: 'Bio--free-to-chat',
|
||||
shortName: '+1',
|
||||
},
|
||||
{
|
||||
i18nLabel: 'Bio--coffee-lover',
|
||||
shortName: 'coffee',
|
||||
},
|
||||
{
|
||||
i18nLabel: 'Bio--taking-break',
|
||||
shortName: 'mobile_phone_off',
|
||||
},
|
||||
];
|
||||
|
||||
export const ProfileEditor = ({
|
||||
aboutEmoji,
|
||||
aboutText,
|
||||
avatarPath,
|
||||
familyName,
|
||||
firstName,
|
||||
i18n,
|
||||
onEditStateChanged,
|
||||
onProfileChanged,
|
||||
onSetSkinTone,
|
||||
recentEmojis,
|
||||
skinTone,
|
||||
}: PropsType): JSX.Element => {
|
||||
const focusInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [editState, setEditState] = useState<EditState>(EditState.None);
|
||||
const [confirmDiscardAction, setConfirmDiscardAction] = useState<
|
||||
(() => unknown) | undefined
|
||||
>(undefined);
|
||||
|
||||
// This is here to avoid component re-render jitters in the time it takes
|
||||
// redux to come back with the correct state
|
||||
const [fullName, setFullName] = useState({
|
||||
familyName,
|
||||
firstName,
|
||||
});
|
||||
const [fullBio, setFullBio] = useState({
|
||||
aboutEmoji,
|
||||
aboutText,
|
||||
});
|
||||
|
||||
const [avatarData, setAvatarData] = useState<ArrayBuffer | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [stagedProfile, setStagedProfile] = useState<ProfileDataType>({
|
||||
aboutEmoji,
|
||||
aboutText,
|
||||
familyName,
|
||||
firstName,
|
||||
});
|
||||
|
||||
let content: JSX.Element;
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setEditState(EditState.None);
|
||||
onEditStateChanged(EditState.None);
|
||||
}, [setEditState, onEditStateChanged]);
|
||||
|
||||
const setAboutEmoji = useCallback(
|
||||
(ev: EmojiPickDataType) => {
|
||||
const emojiData = getEmojiData(ev.shortName, skinTone);
|
||||
setStagedProfile(profileData => ({
|
||||
...profileData,
|
||||
aboutEmoji: unifiedToEmoji(emojiData.unified),
|
||||
}));
|
||||
},
|
||||
[setStagedProfile, skinTone]
|
||||
);
|
||||
|
||||
const handleAvatarChanged = useCallback(
|
||||
(avatar: ArrayBuffer | undefined) => {
|
||||
setAvatarData(avatar);
|
||||
},
|
||||
[setAvatarData]
|
||||
);
|
||||
|
||||
const calculateGraphemeCount = useCallback((name = '') => {
|
||||
return 256 - grapheme.count(name);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const focusNode = focusInputRef.current;
|
||||
if (!focusNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusNode.focus();
|
||||
}, [editState]);
|
||||
|
||||
if (editState === EditState.ProfileName) {
|
||||
content = (
|
||||
<>
|
||||
<Input
|
||||
i18n={i18n}
|
||||
maxGraphemeCount={calculateGraphemeCount(stagedProfile.familyName)}
|
||||
onChange={newFirstName => {
|
||||
setStagedProfile(profileData => ({
|
||||
...profileData,
|
||||
firstName: String(newFirstName),
|
||||
}));
|
||||
}}
|
||||
placeholder={i18n('ProfileEditor--first-name')}
|
||||
ref={focusInputRef}
|
||||
value={stagedProfile.firstName}
|
||||
/>
|
||||
<Input
|
||||
i18n={i18n}
|
||||
maxGraphemeCount={calculateGraphemeCount(stagedProfile.firstName)}
|
||||
onChange={newFamilyName => {
|
||||
setStagedProfile(profileData => ({
|
||||
...profileData,
|
||||
familyName: newFamilyName,
|
||||
}));
|
||||
}}
|
||||
placeholder={i18n('ProfileEditor--last-name')}
|
||||
value={stagedProfile.familyName}
|
||||
/>
|
||||
<div className="ProfileEditor__buttons">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const handleCancel = () => {
|
||||
handleBack();
|
||||
setStagedProfile(profileData => ({
|
||||
...profileData,
|
||||
familyName,
|
||||
firstName,
|
||||
}));
|
||||
};
|
||||
|
||||
const hasChanges =
|
||||
stagedProfile.familyName !== fullName.familyName ||
|
||||
stagedProfile.firstName !== fullName.firstName;
|
||||
if (hasChanges) {
|
||||
setConfirmDiscardAction(() => handleCancel);
|
||||
} else {
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!stagedProfile.firstName}
|
||||
onClick={() => {
|
||||
if (!stagedProfile.firstName) {
|
||||
return;
|
||||
}
|
||||
setFullName({
|
||||
firstName,
|
||||
familyName,
|
||||
});
|
||||
|
||||
onProfileChanged(stagedProfile, avatarData);
|
||||
handleBack();
|
||||
}}
|
||||
>
|
||||
{i18n('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else if (editState === EditState.Bio) {
|
||||
content = (
|
||||
<>
|
||||
<Input
|
||||
expandable
|
||||
hasClearButton
|
||||
i18n={i18n}
|
||||
icon={
|
||||
<div className="module-composition-area__button-cell">
|
||||
<EmojiButton
|
||||
closeOnPick
|
||||
emoji={stagedProfile.aboutEmoji}
|
||||
i18n={i18n}
|
||||
onPickEmoji={setAboutEmoji}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
recentEmojis={recentEmojis}
|
||||
skinTone={skinTone}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
maxGraphemeCount={140}
|
||||
moduleClassName="ProfileEditor__about-input"
|
||||
onChange={value => {
|
||||
if (value) {
|
||||
setStagedProfile(profileData => ({
|
||||
...profileData,
|
||||
aboutEmoji: stagedProfile.aboutEmoji,
|
||||
aboutText: value,
|
||||
}));
|
||||
} else {
|
||||
setStagedProfile(profileData => ({
|
||||
...profileData,
|
||||
aboutEmoji: undefined,
|
||||
aboutText: '',
|
||||
}));
|
||||
}
|
||||
}}
|
||||
ref={focusInputRef}
|
||||
placeholder={i18n('ProfileEditor--about-placeholder')}
|
||||
value={stagedProfile.aboutText}
|
||||
whenToShowRemainingCount={40}
|
||||
/>
|
||||
|
||||
{DEFAULT_BIOS.map(defaultBio => (
|
||||
<PanelRow
|
||||
className="ProfileEditor__row"
|
||||
key={defaultBio.shortName}
|
||||
icon={
|
||||
<div className="ProfileEditor__icon--container">
|
||||
<Emoji shortName={defaultBio.shortName} size={24} />
|
||||
</div>
|
||||
}
|
||||
label={i18n(defaultBio.i18nLabel)}
|
||||
onClick={() => {
|
||||
const emojiData = getEmojiData(defaultBio.shortName, skinTone);
|
||||
|
||||
setStagedProfile(profileData => ({
|
||||
...profileData,
|
||||
aboutEmoji: unifiedToEmoji(emojiData.unified),
|
||||
aboutText: i18n(defaultBio.i18nLabel),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="ProfileEditor__buttons">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const handleCancel = () => {
|
||||
handleBack();
|
||||
setStagedProfile(profileData => ({
|
||||
...profileData,
|
||||
...fullBio,
|
||||
}));
|
||||
};
|
||||
|
||||
const hasChanges =
|
||||
stagedProfile.aboutText !== fullBio.aboutText ||
|
||||
stagedProfile.aboutEmoji !== fullBio.aboutEmoji;
|
||||
if (hasChanges) {
|
||||
setConfirmDiscardAction(() => handleCancel);
|
||||
} else {
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFullBio({
|
||||
aboutEmoji: stagedProfile.aboutEmoji,
|
||||
aboutText: stagedProfile.aboutText,
|
||||
});
|
||||
|
||||
onProfileChanged(stagedProfile, avatarData);
|
||||
handleBack();
|
||||
}}
|
||||
>
|
||||
{i18n('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else if (editState === EditState.None) {
|
||||
const fullNameText = [fullName.firstName, fullName.familyName]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
content = (
|
||||
<>
|
||||
<AvatarInputContainer
|
||||
avatarPath={avatarPath}
|
||||
contextMenuId="edit-self-profile-avatar"
|
||||
i18n={i18n}
|
||||
onAvatarChanged={avatar => {
|
||||
handleAvatarChanged(avatar);
|
||||
onProfileChanged(stagedProfile, avatar);
|
||||
}}
|
||||
onAvatarLoaded={handleAvatarChanged}
|
||||
type={AvatarInputType.Profile}
|
||||
/>
|
||||
|
||||
<hr className="ProfileEditor__divider" />
|
||||
|
||||
<PanelRow
|
||||
className="ProfileEditor__row"
|
||||
icon={
|
||||
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--name" />
|
||||
}
|
||||
label={fullNameText}
|
||||
onClick={() => {
|
||||
setEditState(EditState.ProfileName);
|
||||
onEditStateChanged(EditState.ProfileName);
|
||||
}}
|
||||
/>
|
||||
|
||||
<PanelRow
|
||||
className="ProfileEditor__row"
|
||||
icon={
|
||||
fullBio.aboutEmoji ? (
|
||||
<div className="ProfileEditor__icon--container">
|
||||
<Emoji emoji={fullBio.aboutEmoji} size={24} />
|
||||
</div>
|
||||
) : (
|
||||
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--bio" />
|
||||
)
|
||||
}
|
||||
label={fullBio.aboutText || i18n('ProfileEditor--about')}
|
||||
onClick={() => {
|
||||
setEditState(EditState.Bio);
|
||||
onEditStateChanged(EditState.Bio);
|
||||
}}
|
||||
/>
|
||||
|
||||
<hr className="ProfileEditor__divider" />
|
||||
|
||||
<div className="ProfileEditor__info">
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="ProfileEditor--info"
|
||||
components={{
|
||||
learnMore: (
|
||||
<a
|
||||
href="https://support.signal.org/hc/en-us/articles/360007459591"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{i18n('ProfileEditor--learnMore')}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
throw missingCaseError(editState);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDiscardAction && (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: confirmDiscardAction,
|
||||
text: i18n('discard'),
|
||||
style: 'negative',
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={() => setConfirmDiscardAction(undefined)}
|
||||
>
|
||||
{i18n('ProfileEditor--discard')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
<div className="ProfileEditor">{content}</div>
|
||||
</>
|
||||
);
|
||||
};
|
85
ts/components/ProfileEditorModal.tsx
Normal file
85
ts/components/ProfileEditorModal.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import {
|
||||
ProfileEditor,
|
||||
PropsType as ProfileEditorPropsType,
|
||||
EditState,
|
||||
} from './ProfileEditor';
|
||||
import { ProfileDataType } from '../state/ducks/conversations';
|
||||
|
||||
export type PropsDataType = {
|
||||
hasError: boolean;
|
||||
};
|
||||
|
||||
type PropsType = {
|
||||
myProfileChanged: (
|
||||
profileData: ProfileDataType,
|
||||
avatarData?: ArrayBuffer
|
||||
) => unknown;
|
||||
toggleProfileEditor: () => unknown;
|
||||
toggleProfileEditorHasError: () => unknown;
|
||||
} & PropsDataType &
|
||||
ProfileEditorPropsType;
|
||||
|
||||
export const ProfileEditorModal = ({
|
||||
hasError,
|
||||
i18n,
|
||||
myProfileChanged,
|
||||
onSetSkinTone,
|
||||
toggleProfileEditor,
|
||||
toggleProfileEditorHasError,
|
||||
...restProps
|
||||
}: PropsType): JSX.Element => {
|
||||
const ModalTitles = {
|
||||
None: i18n('ProfileEditorModal--profile'),
|
||||
ProfileName: i18n('ProfileEditorModal--name'),
|
||||
Bio: i18n('ProfileEditorModal--about'),
|
||||
};
|
||||
|
||||
const [modalTitle, setModalTitle] = useState(ModalTitles.None);
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
cancelText={i18n('Confirmation--confirm')}
|
||||
i18n={i18n}
|
||||
onClose={toggleProfileEditorHasError}
|
||||
>
|
||||
{i18n('ProfileEditorModal--error')}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
hasXButton
|
||||
i18n={i18n}
|
||||
onClose={toggleProfileEditor}
|
||||
title={modalTitle}
|
||||
>
|
||||
<ProfileEditor
|
||||
{...restProps}
|
||||
i18n={i18n}
|
||||
onEditStateChanged={editState => {
|
||||
if (editState === EditState.None) {
|
||||
setModalTitle(ModalTitles.None);
|
||||
} else if (editState === EditState.ProfileName) {
|
||||
setModalTitle(ModalTitles.ProfileName);
|
||||
} else if (editState === EditState.Bio) {
|
||||
setModalTitle(ModalTitles.Bio);
|
||||
}
|
||||
}}
|
||||
onProfileChanged={(profileData, avatarData) => {
|
||||
myProfileChanged(profileData, avatarData);
|
||||
}}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -4,21 +4,18 @@
|
|||
import React, {
|
||||
FormEventHandler,
|
||||
FunctionComponent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
import { Modal } from '../../Modal';
|
||||
import { AvatarInput, AvatarInputVariant } from '../../AvatarInput';
|
||||
import { AvatarInputContainer } from '../../AvatarInputContainer';
|
||||
import { AvatarInputVariant } from '../../AvatarInput';
|
||||
import { Button, ButtonVariant } from '../../Button';
|
||||
import { Spinner } from '../../Spinner';
|
||||
import { GroupDescriptionInput } from '../../GroupDescriptionInput';
|
||||
import { GroupTitleInput } from '../../GroupTitleInput';
|
||||
import * as log from '../../../logging/log';
|
||||
import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer';
|
||||
import { RequestState } from './util';
|
||||
|
||||
const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
|
||||
|
@ -77,35 +74,6 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
|||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
@ -154,15 +122,18 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
|||
onSubmit={onSubmit}
|
||||
className="module-EditConversationAttributesModal"
|
||||
>
|
||||
<AvatarInput
|
||||
<AvatarInputContainer
|
||||
avatarPath={externalAvatarPath}
|
||||
contextMenuId="edit conversation attributes avatar input"
|
||||
disabled={isRequestActive}
|
||||
i18n={i18n}
|
||||
onChange={newAvatar => {
|
||||
onAvatarChanged={newAvatar => {
|
||||
setAvatar(newAvatar);
|
||||
setHasAvatarChanged(true);
|
||||
}}
|
||||
value={avatar}
|
||||
onAvatarLoaded={loadedAvatar => {
|
||||
setAvatar(loadedAvatar);
|
||||
}}
|
||||
variant={AvatarInputVariant.Dark}
|
||||
/>
|
||||
|
||||
|
@ -217,25 +188,3 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
|||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -6,10 +6,13 @@ import classNames from 'classnames';
|
|||
import { get, noop } from 'lodash';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Emoji } from './Emoji';
|
||||
import { EmojiPicker, Props as EmojiPickerProps } from './EmojiPicker';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
export type OwnProps = {
|
||||
readonly closeOnPick?: boolean;
|
||||
readonly emoji?: string;
|
||||
readonly i18n: LocalizerType;
|
||||
readonly onClose?: () => unknown;
|
||||
};
|
||||
|
@ -22,6 +25,8 @@ export type Props = OwnProps &
|
|||
|
||||
export const EmojiButton = React.memo(
|
||||
({
|
||||
closeOnPick,
|
||||
emoji,
|
||||
i18n,
|
||||
doSend,
|
||||
onClose,
|
||||
|
@ -114,9 +119,12 @@ export const EmojiButton = React.memo(
|
|||
className={classNames({
|
||||
'module-emoji-button__button': true,
|
||||
'module-emoji-button__button--active': open,
|
||||
'module-emoji-button__button--has-emoji': Boolean(emoji),
|
||||
})}
|
||||
aria-label={i18n('EmojiButton__label')}
|
||||
/>
|
||||
>
|
||||
{emoji && <Emoji emoji={emoji} size={24} />}
|
||||
</button>
|
||||
)}
|
||||
</Reference>
|
||||
{open && popperRoot
|
||||
|
@ -127,7 +135,12 @@ export const EmojiButton = React.memo(
|
|||
ref={ref}
|
||||
i18n={i18n}
|
||||
style={style}
|
||||
onPickEmoji={onPickEmoji}
|
||||
onPickEmoji={ev => {
|
||||
onPickEmoji(ev);
|
||||
if (closeOnPick) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
doSend={doSend}
|
||||
onClose={handleClose}
|
||||
skinTone={skinTone}
|
||||
|
|
|
@ -1366,6 +1366,8 @@ export class ConversationModel extends window.Backbone
|
|||
e164: this.get('e164'),
|
||||
|
||||
about: this.getAboutText(),
|
||||
aboutText: this.get('about'),
|
||||
aboutEmoji: this.get('aboutEmoji'),
|
||||
acceptedMessageRequest: this.getAccepted(),
|
||||
activeAt: this.get('active_at')!,
|
||||
areWePending: Boolean(
|
||||
|
@ -1378,6 +1380,7 @@ export class ConversationModel extends window.Backbone
|
|||
canChangeTimer: this.canChangeTimer(),
|
||||
canEditGroupInfo: this.canEditGroupInfo(),
|
||||
avatarPath: this.getAbsoluteAvatarPath(),
|
||||
avatarHash: this.getAvatarHash(),
|
||||
unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(),
|
||||
color,
|
||||
conversationColor: this.getConversationColor(),
|
||||
|
@ -1387,6 +1390,7 @@ export class ConversationModel extends window.Backbone
|
|||
draftBodyRanges,
|
||||
draftPreview,
|
||||
draftText,
|
||||
familyName: this.get('profileFamilyName'),
|
||||
firstName: this.get('profileName')!,
|
||||
groupDescription: this.get('description'),
|
||||
groupVersion,
|
||||
|
@ -1417,6 +1421,7 @@ export class ConversationModel extends window.Backbone
|
|||
messageCount: this.get('messageCount') || 0,
|
||||
pendingMemberships: this.getPendingMemberships(),
|
||||
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
|
||||
profileKey: this.get('profileKey'),
|
||||
messageRequestsEnabled,
|
||||
accessControlAddFromInviteLink: this.get('accessControl')
|
||||
?.addFromInviteLink,
|
||||
|
@ -4522,6 +4527,10 @@ export class ConversationModel extends window.Backbone
|
|||
c.unset('aboutEmoji');
|
||||
}
|
||||
|
||||
if (profile.paymentAddress && isMe(c.attributes)) {
|
||||
window.storage.put('paymentAddress', profile.paymentAddress);
|
||||
}
|
||||
|
||||
if (profile.capabilities) {
|
||||
c.set({ capabilities: profile.capabilities });
|
||||
} else {
|
||||
|
@ -4896,6 +4905,13 @@ export class ConversationModel extends window.Backbone
|
|||
return avatar?.path || undefined;
|
||||
}
|
||||
|
||||
private getAvatarHash(): undefined | string {
|
||||
const avatar = isMe(this.attributes)
|
||||
? this.get('profileAvatar') || this.get('avatar')
|
||||
: this.get('avatar') || this.get('profileAvatar');
|
||||
return avatar?.hash || undefined;
|
||||
}
|
||||
|
||||
getAbsoluteAvatarPath(): string | undefined {
|
||||
const avatarPath = this.getAvatarPath();
|
||||
return avatarPath ? getAbsoluteAttachmentPath(avatarPath) : undefined;
|
||||
|
|
88
ts/services/writeProfile.ts
Normal file
88
ts/services/writeProfile.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { computeHash } from '../Crypto';
|
||||
import dataInterface from '../sql/Client';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { encryptProfileData } from '../util/encryptProfileData';
|
||||
|
||||
export async function writeProfile(
|
||||
conversation: ConversationType,
|
||||
avatarData?: ArrayBuffer
|
||||
): Promise<void> {
|
||||
// Before we write anything we request the user's profile so that we can
|
||||
// have an up-to-date paymentAddress to be able to include it when we write
|
||||
const model = window.ConversationController.get(conversation.id);
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
await model.getProfile(model.get('uuid'), model.get('e164'));
|
||||
|
||||
// Encrypt the profile data, update profile, and if needed upload the avatar
|
||||
const {
|
||||
aboutEmoji,
|
||||
aboutText,
|
||||
avatarHash,
|
||||
avatarPath,
|
||||
familyName,
|
||||
firstName,
|
||||
} = conversation;
|
||||
|
||||
const [profileData, encryptedAvatarData] = await encryptProfileData(
|
||||
conversation,
|
||||
avatarData
|
||||
);
|
||||
const avatarRequestHeaders = await window.textsecure.messaging.putProfile(
|
||||
profileData
|
||||
);
|
||||
|
||||
// Upload the avatar if provided
|
||||
// delete existing files on disk if avatar has been removed
|
||||
// update the account's avatar path and hash if it's a new avatar
|
||||
let profileAvatar:
|
||||
| {
|
||||
hash: string;
|
||||
path: string;
|
||||
}
|
||||
| undefined;
|
||||
if (avatarRequestHeaders && encryptedAvatarData && avatarData) {
|
||||
await window.textsecure.messaging.uploadAvatar(
|
||||
avatarRequestHeaders,
|
||||
encryptedAvatarData
|
||||
);
|
||||
|
||||
const hash = await computeHash(avatarData);
|
||||
|
||||
if (hash !== avatarHash) {
|
||||
const [path] = await Promise.all([
|
||||
window.Signal.Migrations.writeNewAttachmentData(avatarData),
|
||||
avatarPath
|
||||
? window.Signal.Migrations.deleteAttachmentData(avatarPath)
|
||||
: undefined,
|
||||
]);
|
||||
profileAvatar = {
|
||||
hash,
|
||||
path,
|
||||
};
|
||||
} else {
|
||||
profileAvatar = {
|
||||
hash: String(avatarHash),
|
||||
path: String(avatarPath),
|
||||
};
|
||||
}
|
||||
} else if (avatarPath) {
|
||||
await window.Signal.Migrations.deleteAttachmentData(avatarPath);
|
||||
}
|
||||
|
||||
// Update backbone, update DB, run storage service upload
|
||||
model.set({
|
||||
about: aboutText,
|
||||
aboutEmoji,
|
||||
profileAvatar,
|
||||
profileName: firstName,
|
||||
profileFamilyName: familyName,
|
||||
});
|
||||
|
||||
dataInterface.updateConversation(model.attributes);
|
||||
model.captureChange('writeProfile');
|
||||
}
|
|
@ -17,11 +17,16 @@ import {
|
|||
|
||||
import { StateType as RootStateType } from '../reducer';
|
||||
import * as groups from '../../groups';
|
||||
import * as log from '../../logging/log';
|
||||
import { calling } from '../../services/calling';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import { assert } from '../../util/assert';
|
||||
import * as universalExpireTimer from '../../util/universalExpireTimer';
|
||||
import { trigger } from '../../shims/events';
|
||||
import {
|
||||
TOGGLE_PROFILE_EDITOR_ERROR,
|
||||
ToggleProfileEditorErrorActionType,
|
||||
} from './globalModals';
|
||||
|
||||
import {
|
||||
AvatarColorType,
|
||||
|
@ -45,6 +50,8 @@ import {
|
|||
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
|
||||
import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||
import { writeProfile } from '../../services/writeProfile';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
|
||||
import { NoopActionType } from './noop';
|
||||
|
||||
|
@ -72,10 +79,14 @@ export type ConversationType = {
|
|||
uuid?: string;
|
||||
e164?: string;
|
||||
name?: string;
|
||||
familyName?: string;
|
||||
firstName?: string;
|
||||
profileName?: string;
|
||||
about?: string;
|
||||
aboutText?: string;
|
||||
aboutEmoji?: string;
|
||||
avatarPath?: string;
|
||||
avatarHash?: string;
|
||||
unblurredAvatarPath?: string;
|
||||
areWeAdmin?: boolean;
|
||||
areWePending?: boolean;
|
||||
|
@ -157,7 +168,12 @@ export type ConversationType = {
|
|||
secretParams?: string;
|
||||
publicParams?: string;
|
||||
acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle;
|
||||
profileKey?: string;
|
||||
};
|
||||
export type ProfileDataType = {
|
||||
firstName: string;
|
||||
} & Pick<ConversationType, 'aboutEmoji' | 'aboutText' | 'familyName'>;
|
||||
|
||||
export type ConversationLookupType = {
|
||||
[key: string]: ConversationType;
|
||||
};
|
||||
|
@ -673,6 +689,7 @@ export const actions = {
|
|||
messagesAdded,
|
||||
messageSizeChanged,
|
||||
messagesReset,
|
||||
myProfileChanged,
|
||||
openConversationExternal,
|
||||
openConversationInternal,
|
||||
removeAllConversations,
|
||||
|
@ -704,6 +721,41 @@ export const actions = {
|
|||
toggleConversationInChooseMembers,
|
||||
};
|
||||
|
||||
function myProfileChanged(
|
||||
profileData: ProfileDataType,
|
||||
avatarData?: ArrayBuffer
|
||||
): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
NoopActionType | ToggleProfileEditorErrorActionType
|
||||
> {
|
||||
return async (dispatch, getState) => {
|
||||
const conversation = getMe(getState());
|
||||
|
||||
try {
|
||||
await writeProfile(
|
||||
{
|
||||
...conversation,
|
||||
...profileData,
|
||||
},
|
||||
avatarData
|
||||
);
|
||||
|
||||
// writeProfile above updates the backbone model which in turn updates
|
||||
// redux through it's on:change event listener. Once we lose Backbone
|
||||
// we'll need to manually sync these new changes.
|
||||
dispatch({
|
||||
type: 'NOOP',
|
||||
payload: null,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('myProfileChanged', err && err.stack ? err.stack : err);
|
||||
dispatch({ type: TOGGLE_PROFILE_EDITOR_ERROR });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function removeCustomColorOnConversations(
|
||||
colorId: string
|
||||
): ThunkAction<void, RootStateType, unknown, CustomColorRemovedActionType> {
|
||||
|
|
|
@ -5,33 +5,61 @@
|
|||
|
||||
export type GlobalModalsStateType = {
|
||||
readonly isChatColorEditorVisible: boolean;
|
||||
readonly isProfileEditorVisible: boolean;
|
||||
readonly profileEditorHasError: boolean;
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
||||
const TOGGLE_CHAT_COLOR_EDITOR = 'globalModals/TOGGLE_CHAT_COLOR_EDITOR';
|
||||
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
|
||||
export const TOGGLE_PROFILE_EDITOR_ERROR =
|
||||
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
|
||||
|
||||
type ToggleChatColorEditorActionType = {
|
||||
type: typeof TOGGLE_CHAT_COLOR_EDITOR;
|
||||
};
|
||||
|
||||
export type GlobalModalsActionType = ToggleChatColorEditorActionType;
|
||||
type ToggleProfileEditorActionType = {
|
||||
type: typeof TOGGLE_PROFILE_EDITOR;
|
||||
};
|
||||
|
||||
export type ToggleProfileEditorErrorActionType = {
|
||||
type: typeof TOGGLE_PROFILE_EDITOR_ERROR;
|
||||
};
|
||||
|
||||
export type GlobalModalsActionType =
|
||||
| ToggleChatColorEditorActionType
|
||||
| ToggleProfileEditorActionType
|
||||
| ToggleProfileEditorErrorActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
export const actions = {
|
||||
toggleChatColorEditor,
|
||||
toggleProfileEditor,
|
||||
toggleProfileEditorHasError,
|
||||
};
|
||||
|
||||
function toggleChatColorEditor(): ToggleChatColorEditorActionType {
|
||||
return { type: TOGGLE_CHAT_COLOR_EDITOR };
|
||||
}
|
||||
|
||||
function toggleProfileEditor(): ToggleProfileEditorActionType {
|
||||
return { type: TOGGLE_PROFILE_EDITOR };
|
||||
}
|
||||
|
||||
function toggleProfileEditorHasError(): ToggleProfileEditorErrorActionType {
|
||||
return { type: TOGGLE_PROFILE_EDITOR_ERROR };
|
||||
}
|
||||
|
||||
// Reducer
|
||||
|
||||
export function getEmptyState(): GlobalModalsStateType {
|
||||
return {
|
||||
isChatColorEditorVisible: false,
|
||||
isProfileEditorVisible: false,
|
||||
profileEditorHasError: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -46,5 +74,19 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === TOGGLE_PROFILE_EDITOR) {
|
||||
return {
|
||||
...state,
|
||||
isProfileEditorVisible: !state.isProfileEditorVisible,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === TOGGLE_PROFILE_EDITOR_ERROR) {
|
||||
return {
|
||||
...state,
|
||||
profileEditorHasError: !state.profileEditorHasError,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -8,16 +8,28 @@ import { GlobalModalContainer } from '../../components/GlobalModalContainer';
|
|||
import { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { SmartChatColorPicker } from './ChatColorPicker';
|
||||
import { SmartProfileEditorModal } from './ProfileEditorModal';
|
||||
|
||||
// Workaround: A react component's required properties are filtering up through connect()
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const FilteredSmartProfileEditorModal = SmartProfileEditorModal as any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
function renderChatColorPicker(): JSX.Element {
|
||||
return <SmartChatColorPicker />;
|
||||
}
|
||||
|
||||
function renderProfileEditor(): JSX.Element {
|
||||
return <FilteredSmartProfileEditorModal />;
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: StateType) => {
|
||||
return {
|
||||
...state.globalModals,
|
||||
i18n: getIntl(state),
|
||||
renderChatColorPicker,
|
||||
renderProfileEditor,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
41
ts/state/smart/ProfileEditorModal.ts
Normal file
41
ts/state/smart/ProfileEditorModal.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { get } from 'lodash';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
import {
|
||||
ProfileEditorModal,
|
||||
PropsDataType as ProfileEditorModalPropsType,
|
||||
} from '../../components/ProfileEditorModal';
|
||||
import { PropsDataType } from '../../components/ProfileEditor';
|
||||
import { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
|
||||
function mapStateToProps(
|
||||
state: StateType
|
||||
): PropsDataType & ProfileEditorModalPropsType {
|
||||
const { avatarPath, aboutText, aboutEmoji, firstName, familyName } = getMe(
|
||||
state
|
||||
);
|
||||
const recentEmojis = selectRecentEmojis(state);
|
||||
const skinTone = get(state, ['items', 'skinTone'], 0);
|
||||
|
||||
return {
|
||||
aboutEmoji,
|
||||
aboutText,
|
||||
avatarPath,
|
||||
familyName,
|
||||
firstName: String(firstName),
|
||||
hasError: state.globalModals.profileEditorHasError,
|
||||
i18n: getIntl(state),
|
||||
recentEmojis,
|
||||
skinTone,
|
||||
};
|
||||
}
|
||||
|
||||
const smart = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export const SmartProfileEditorModal = smart(ProfileEditorModal);
|
|
@ -311,8 +311,8 @@ const LAST_NAMES = [
|
|||
'Jimenez',
|
||||
];
|
||||
|
||||
const getFirstName = (): string => sample(FIRST_NAMES) || 'Test';
|
||||
const getLastName = (): string => sample(LAST_NAMES) || 'Test';
|
||||
export const getFirstName = (): string => sample(FIRST_NAMES) || 'Test';
|
||||
export const getLastName = (): string => sample(LAST_NAMES) || 'Test';
|
||||
|
||||
export function getDefaultConversation(
|
||||
overrideProps: Partial<ConversationType> = {}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { assert } from 'chai';
|
|||
|
||||
import * as Curve from '../Curve';
|
||||
import * as Crypto from '../Crypto';
|
||||
import TSCrypto from '../textsecure/Crypto';
|
||||
import TSCrypto, { PaddedLengths } from '../textsecure/Crypto';
|
||||
|
||||
describe('Crypto', () => {
|
||||
describe('encrypting and decrypting profile data', () => {
|
||||
|
@ -16,8 +16,12 @@ describe('Crypto', () => {
|
|||
const buffer = Crypto.bytesFromString(name);
|
||||
const key = Crypto.getRandomBytes(32);
|
||||
|
||||
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
|
||||
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
|
||||
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
|
||||
buffer,
|
||||
key,
|
||||
PaddedLengths.Name
|
||||
);
|
||||
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
|
||||
|
||||
const { given, family } = await TSCrypto.decryptProfileName(
|
||||
Crypto.arrayBufferToBase64(encrypted),
|
||||
|
@ -32,8 +36,12 @@ describe('Crypto', () => {
|
|||
const buffer = Crypto.bytesFromString(name);
|
||||
const key = Crypto.getRandomBytes(32);
|
||||
|
||||
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
|
||||
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
|
||||
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
|
||||
buffer,
|
||||
key,
|
||||
PaddedLengths.Name
|
||||
);
|
||||
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
|
||||
const { given, family } = await TSCrypto.decryptProfileName(
|
||||
Crypto.arrayBufferToBase64(encrypted),
|
||||
key
|
||||
|
@ -49,8 +57,12 @@ describe('Crypto', () => {
|
|||
const buffer = Crypto.bytesFromString(name);
|
||||
const key = Crypto.getRandomBytes(32);
|
||||
|
||||
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
|
||||
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
|
||||
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
|
||||
buffer,
|
||||
key,
|
||||
PaddedLengths.Name
|
||||
);
|
||||
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
|
||||
const { given, family } = await TSCrypto.decryptProfileName(
|
||||
Crypto.arrayBufferToBase64(encrypted),
|
||||
key
|
||||
|
@ -70,8 +82,12 @@ describe('Crypto', () => {
|
|||
const buffer = Crypto.bytesFromString(name);
|
||||
const key = Crypto.getRandomBytes(32);
|
||||
|
||||
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
|
||||
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
|
||||
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
|
||||
buffer,
|
||||
key,
|
||||
PaddedLengths.Name
|
||||
);
|
||||
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
|
||||
const { given, family } = await TSCrypto.decryptProfileName(
|
||||
Crypto.arrayBufferToBase64(encrypted),
|
||||
key
|
||||
|
@ -84,8 +100,12 @@ describe('Crypto', () => {
|
|||
const name = Crypto.bytesFromString('');
|
||||
const key = Crypto.getRandomBytes(32);
|
||||
|
||||
const encrypted = await TSCrypto.encryptProfileName(name, key);
|
||||
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
|
||||
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
|
||||
name,
|
||||
key,
|
||||
PaddedLengths.Name
|
||||
);
|
||||
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
|
||||
|
||||
const { given, family } = await TSCrypto.decryptProfileName(
|
||||
Crypto.arrayBufferToBase64(encrypted),
|
||||
|
|
85
ts/test-electron/util/encryptProfileData_test.ts
Normal file
85
ts/test-electron/util/encryptProfileData_test.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import Crypto from '../../textsecure/Crypto';
|
||||
import {
|
||||
arrayBufferToBase64,
|
||||
base64ToArrayBuffer,
|
||||
stringFromBytes,
|
||||
trimForDisplay,
|
||||
} from '../../Crypto';
|
||||
import { encryptProfileData } from '../../util/encryptProfileData';
|
||||
|
||||
describe('encryptProfileData', () => {
|
||||
it('encrypts and decrypts properly', async () => {
|
||||
const keyBuffer = Crypto.getRandomBytes(32);
|
||||
const conversation = {
|
||||
aboutEmoji: '🐢',
|
||||
aboutText: 'I like turtles',
|
||||
familyName: 'Kid',
|
||||
firstName: 'Zombie',
|
||||
profileKey: arrayBufferToBase64(keyBuffer),
|
||||
uuid: uuid(),
|
||||
|
||||
// To satisfy TS
|
||||
acceptedMessageRequest: true,
|
||||
id: '',
|
||||
isMe: true,
|
||||
sharedGroupNames: [],
|
||||
title: '',
|
||||
type: 'direct' as const,
|
||||
};
|
||||
|
||||
const [encrypted] = await encryptProfileData(conversation);
|
||||
|
||||
assert.isDefined(encrypted.version);
|
||||
assert.isDefined(encrypted.name);
|
||||
assert.isDefined(encrypted.commitment);
|
||||
|
||||
const decryptedProfileNameBytes = await Crypto.decryptProfileName(
|
||||
encrypted.name,
|
||||
keyBuffer
|
||||
);
|
||||
assert.equal(
|
||||
stringFromBytes(decryptedProfileNameBytes.given),
|
||||
conversation.firstName
|
||||
);
|
||||
if (decryptedProfileNameBytes.family) {
|
||||
assert.equal(
|
||||
stringFromBytes(decryptedProfileNameBytes.family),
|
||||
conversation.familyName
|
||||
);
|
||||
} else {
|
||||
assert.isDefined(decryptedProfileNameBytes.family);
|
||||
}
|
||||
|
||||
if (encrypted.about) {
|
||||
const decryptedAboutBytes = await Crypto.decryptProfile(
|
||||
base64ToArrayBuffer(encrypted.about),
|
||||
keyBuffer
|
||||
);
|
||||
assert.equal(
|
||||
stringFromBytes(trimForDisplay(decryptedAboutBytes)),
|
||||
conversation.aboutText
|
||||
);
|
||||
} else {
|
||||
assert.isDefined(encrypted.about);
|
||||
}
|
||||
|
||||
if (encrypted.aboutEmoji) {
|
||||
const decryptedAboutEmojiBytes = await Crypto.decryptProfile(
|
||||
base64ToArrayBuffer(encrypted.aboutEmoji),
|
||||
keyBuffer
|
||||
);
|
||||
assert.equal(
|
||||
stringFromBytes(trimForDisplay(decryptedAboutEmojiBytes)),
|
||||
conversation.aboutEmoji
|
||||
);
|
||||
} else {
|
||||
assert.isDefined(encrypted.aboutEmoji);
|
||||
}
|
||||
});
|
||||
});
|
19
ts/test-electron/util/imagePathToArrayBuffer_test.ts
Normal file
19
ts/test-electron/util/imagePathToArrayBuffer_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 path from 'path';
|
||||
|
||||
import { imagePathToArrayBuffer } from '../../util/imagePathToArrayBuffer';
|
||||
|
||||
describe('imagePathToArrayBuffer', () => {
|
||||
it('converts an image to an ArrayBuffer', async () => {
|
||||
const avatarPath = path.join(
|
||||
__dirname,
|
||||
'../../../fixtures/kitten-3-64-64.jpg'
|
||||
);
|
||||
const buffer = await imagePathToArrayBuffer(avatarPath);
|
||||
assert.isDefined(buffer);
|
||||
assert(buffer instanceof ArrayBuffer);
|
||||
});
|
||||
});
|
|
@ -117,7 +117,14 @@ declare global {
|
|||
const PROFILE_IV_LENGTH = 12; // bytes
|
||||
const PROFILE_KEY_LENGTH = 32; // bytes
|
||||
const PROFILE_TAG_LENGTH = 128; // bits
|
||||
const PROFILE_NAME_PADDED_LENGTH = 53; // bytes
|
||||
|
||||
// bytes
|
||||
export const PaddedLengths = {
|
||||
Name: [53, 257],
|
||||
About: [128, 254, 512],
|
||||
AboutEmoji: [32],
|
||||
PaymentAddress: [554],
|
||||
};
|
||||
|
||||
type EncryptedAttachment = {
|
||||
ciphertext: ArrayBuffer;
|
||||
|
@ -324,13 +331,20 @@ const Crypto = {
|
|||
);
|
||||
},
|
||||
|
||||
async encryptProfileName(
|
||||
name: ArrayBuffer,
|
||||
key: ArrayBuffer
|
||||
async encryptProfileItemWithPadding(
|
||||
item: ArrayBuffer,
|
||||
profileKey: ArrayBuffer,
|
||||
paddedLengths: typeof PaddedLengths[keyof typeof PaddedLengths]
|
||||
): Promise<ArrayBuffer> {
|
||||
const padded = new Uint8Array(PROFILE_NAME_PADDED_LENGTH);
|
||||
padded.set(new Uint8Array(name));
|
||||
return Crypto.encryptProfile(padded.buffer as ArrayBuffer, key);
|
||||
const paddedLength = paddedLengths.find(
|
||||
(length: number) => item.byteLength <= length
|
||||
);
|
||||
if (!paddedLength) {
|
||||
throw new Error('Oversized value');
|
||||
}
|
||||
const padded = new Uint8Array(paddedLength);
|
||||
padded.set(new Uint8Array(item));
|
||||
return Crypto.encryptProfile(padded.buffer as ArrayBuffer, profileKey);
|
||||
},
|
||||
|
||||
async decryptProfileName(
|
||||
|
|
|
@ -20,12 +20,14 @@ import { assert } from '../util/assert';
|
|||
import { parseIntOrThrow } from '../util/parseIntOrThrow';
|
||||
import { SenderKeys } from '../LibSignalStores';
|
||||
import {
|
||||
ChallengeType,
|
||||
GroupCredentialsType,
|
||||
GroupLogResponseType,
|
||||
ProxiedRequestOptionsType,
|
||||
ChallengeType,
|
||||
WebAPIType,
|
||||
MultiRecipient200ResponseType,
|
||||
ProfileRequestDataType,
|
||||
ProxiedRequestOptionsType,
|
||||
UploadAvatarHeadersType,
|
||||
WebAPIType,
|
||||
} from './WebAPI';
|
||||
import createTaskWithTimeout from './TaskWithTimeout';
|
||||
import OutgoingMessage, {
|
||||
|
@ -2184,4 +2186,17 @@ export default class MessageSender {
|
|||
): Promise<void> {
|
||||
return this.server.sendChallengeResponse(challengeResponse);
|
||||
}
|
||||
|
||||
async putProfile(
|
||||
jsonData: ProfileRequestDataType
|
||||
): Promise<UploadAvatarHeadersType | undefined> {
|
||||
return this.server.putProfile(jsonData);
|
||||
}
|
||||
|
||||
async uploadAvatar(
|
||||
requestHeaders: UploadAvatarHeadersType,
|
||||
avatarData: ArrayBuffer
|
||||
): Promise<string> {
|
||||
return this.server.uploadAvatar(requestHeaders, avatarData);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -920,6 +920,29 @@ export type GroupLogResponseType = {
|
|||
changes: Proto.GroupChanges;
|
||||
};
|
||||
|
||||
export type ProfileRequestDataType = {
|
||||
about: string | null;
|
||||
aboutEmoji: string | null;
|
||||
avatar: boolean;
|
||||
commitment: string;
|
||||
name: string;
|
||||
paymentAddress: string | null;
|
||||
version: string;
|
||||
};
|
||||
|
||||
const uploadAvatarHeadersZod = z
|
||||
.object({
|
||||
acl: z.string(),
|
||||
algorithm: z.string(),
|
||||
credential: z.string(),
|
||||
date: z.string(),
|
||||
key: z.string(),
|
||||
policy: z.string(),
|
||||
signature: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
export type UploadAvatarHeadersType = z.infer<typeof uploadAvatarHeadersZod>;
|
||||
|
||||
export type WebAPIType = {
|
||||
confirmCode: (
|
||||
number: string,
|
||||
|
@ -1017,6 +1040,9 @@ export type WebAPIType = {
|
|||
) => Promise<Proto.IGroupChange>;
|
||||
modifyStorageRecords: MessageSender['modifyStorageRecords'];
|
||||
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
|
||||
putProfile: (
|
||||
jsonData: ProfileRequestDataType
|
||||
) => Promise<UploadAvatarHeadersType | undefined>;
|
||||
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
|
||||
putStickers: (
|
||||
encryptedManifest: ArrayBuffer,
|
||||
|
@ -1050,6 +1076,10 @@ export type WebAPIType = {
|
|||
) => Promise<MultiRecipient200ResponseType>;
|
||||
setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise<void>;
|
||||
updateDeviceName: (deviceName: string) => Promise<void>;
|
||||
uploadAvatar: (
|
||||
uploadAvatarRequestHeaders: UploadAvatarHeadersType,
|
||||
avatarData: ArrayBuffer
|
||||
) => Promise<string>;
|
||||
uploadGroupAvatar: (
|
||||
avatarData: Uint8Array,
|
||||
options: GroupCredentialsType
|
||||
|
@ -1209,6 +1239,7 @@ export function initialize({
|
|||
modifyGroup,
|
||||
modifyStorageRecords,
|
||||
putAttachment,
|
||||
putProfile,
|
||||
putStickers,
|
||||
registerCapabilities,
|
||||
registerKeys,
|
||||
|
@ -1222,6 +1253,7 @@ export function initialize({
|
|||
sendWithSenderKey,
|
||||
setSignedPreKey,
|
||||
updateDeviceName,
|
||||
uploadAvatar,
|
||||
uploadGroupAvatar,
|
||||
whoami,
|
||||
sendChallengeResponse,
|
||||
|
@ -1424,6 +1456,23 @@ export function initialize({
|
|||
});
|
||||
}
|
||||
|
||||
async function putProfile(
|
||||
jsonData: ProfileRequestDataType
|
||||
): Promise<UploadAvatarHeadersType | undefined> {
|
||||
const res = await _ajax({
|
||||
call: 'profile',
|
||||
httpType: 'PUT',
|
||||
jsonData,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(res);
|
||||
return uploadAvatarHeadersZod.parse(parsed);
|
||||
}
|
||||
|
||||
async function getProfileUnauth(
|
||||
identifier: string,
|
||||
options: {
|
||||
|
@ -2195,6 +2244,27 @@ export function initialize({
|
|||
};
|
||||
}
|
||||
|
||||
async function uploadAvatar(
|
||||
uploadAvatarRequestHeaders: UploadAvatarHeadersType,
|
||||
avatarData: ArrayBuffer
|
||||
): Promise<string> {
|
||||
const verified = verifyAttributes(uploadAvatarRequestHeaders);
|
||||
const { key } = verified;
|
||||
|
||||
const manifestParams = makePutParams(verified, avatarData);
|
||||
|
||||
await _outerAjax(`${cdnUrlObject['0']}/`, {
|
||||
...manifestParams,
|
||||
certificateAuthority,
|
||||
proxyUrl,
|
||||
timeout: 0,
|
||||
type: 'POST',
|
||||
version,
|
||||
});
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
async function uploadGroupAvatar(
|
||||
avatarData: Uint8Array,
|
||||
options: GroupCredentialsType
|
||||
|
|
1
ts/types/Storage.d.ts
vendored
1
ts/types/Storage.d.ts
vendored
|
@ -109,6 +109,7 @@ export type StorageAccessType = {
|
|||
'indexeddb-delete-needed': boolean;
|
||||
senderCertificate: SerializedCertificateType;
|
||||
senderCertificateNoE164: SerializedCertificateType;
|
||||
paymentAddress: string;
|
||||
|
||||
// Deprecated
|
||||
senderCertificateWithUuid: never;
|
||||
|
|
76
ts/util/encryptProfileData.ts
Normal file
76
ts/util/encryptProfileData.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Crypto, { PaddedLengths } from '../textsecure/Crypto';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { ProfileRequestDataType } from '../textsecure/WebAPI';
|
||||
import { assert } from './assert';
|
||||
import {
|
||||
arrayBufferToBase64,
|
||||
base64ToArrayBuffer,
|
||||
bytesFromString,
|
||||
} from '../Crypto';
|
||||
import { deriveProfileKeyCommitment, deriveProfileKeyVersion } from './zkgroup';
|
||||
|
||||
const { encryptProfile, encryptProfileItemWithPadding } = Crypto;
|
||||
|
||||
export async function encryptProfileData(
|
||||
conversation: ConversationType,
|
||||
avatarData?: ArrayBuffer
|
||||
): Promise<[ProfileRequestDataType, ArrayBuffer | undefined]> {
|
||||
const {
|
||||
aboutEmoji,
|
||||
aboutText,
|
||||
familyName,
|
||||
firstName,
|
||||
profileKey,
|
||||
uuid,
|
||||
} = conversation;
|
||||
|
||||
assert(profileKey, 'profileKey');
|
||||
assert(uuid, 'uuid');
|
||||
|
||||
const keyBuffer = base64ToArrayBuffer(profileKey);
|
||||
|
||||
const fullName = [firstName, familyName].filter(Boolean).join('\0');
|
||||
|
||||
const [
|
||||
bytesName,
|
||||
bytesAbout,
|
||||
bytesAboutEmoji,
|
||||
encryptedAvatarData,
|
||||
] = await Promise.all([
|
||||
encryptProfileItemWithPadding(
|
||||
bytesFromString(fullName),
|
||||
keyBuffer,
|
||||
PaddedLengths.Name
|
||||
),
|
||||
aboutText
|
||||
? encryptProfileItemWithPadding(
|
||||
bytesFromString(aboutText),
|
||||
keyBuffer,
|
||||
PaddedLengths.About
|
||||
)
|
||||
: null,
|
||||
aboutEmoji
|
||||
? encryptProfileItemWithPadding(
|
||||
bytesFromString(aboutEmoji),
|
||||
keyBuffer,
|
||||
PaddedLengths.AboutEmoji
|
||||
)
|
||||
: null,
|
||||
avatarData ? encryptProfile(avatarData, keyBuffer) : undefined,
|
||||
]);
|
||||
|
||||
const profileData = {
|
||||
version: deriveProfileKeyVersion(profileKey, uuid),
|
||||
name: arrayBufferToBase64(bytesName),
|
||||
about: bytesAbout ? arrayBufferToBase64(bytesAbout) : null,
|
||||
aboutEmoji: bytesAboutEmoji ? arrayBufferToBase64(bytesAboutEmoji) : null,
|
||||
paymentAddress: window.storage.get('paymentAddress') || null,
|
||||
avatar: Boolean(avatarData),
|
||||
commitment: deriveProfileKeyCommitment(profileKey, uuid),
|
||||
};
|
||||
|
||||
return [profileData, encryptedAvatarData];
|
||||
}
|
28
ts/util/imagePathToArrayBuffer.ts
Normal file
28
ts/util/imagePathToArrayBuffer.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { canvasToArrayBuffer } from './canvasToArrayBuffer';
|
||||
|
||||
export 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;
|
||||
}
|
|
@ -13257,6 +13257,13 @@
|
|||
"updated": "2021-03-01T18:34:36.638Z",
|
||||
"reasonDetail": "Used to reference popup menu"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/AvatarInputContainer.js",
|
||||
"line": " const startingAvatarPathRef = react_1.useRef(avatarPath);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-07-14T00:50:58.330Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/AvatarPopup.js",
|
||||
|
@ -13544,51 +13551,6 @@
|
|||
"updated": "2021-06-17T20:46:02.342Z",
|
||||
"reasonDetail": "Doesn't reference the DOM."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/GroupDescriptionInput.js",
|
||||
"line": " const innerRef = react_1.useRef(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-05-29T02:15:39.186Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/GroupDescriptionInput.js",
|
||||
"line": " const valueOnKeydownRef = react_1.useRef(value);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-05-29T02:15:39.186Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/GroupDescriptionInput.js",
|
||||
"line": " const selectionStartOnKeydownRef = react_1.useRef(value.length);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-05-29T02:15:39.186Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/GroupTitleInput.js",
|
||||
"line": " const selectionStartOnKeydownRef = react_1.useRef(value.length);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-03-05T16:51:54.214Z",
|
||||
"reasonDetail": "Only stores a number."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/GroupTitleInput.js",
|
||||
"line": " const valueOnKeydownRef = react_1.useRef(value);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-03-05T16:51:54.214Z",
|
||||
"reasonDetail": "Only stores a string."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/GroupTitleInput.js",
|
||||
"line": " const innerRef = react_1.useRef(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-03-05T16:51:54.214Z",
|
||||
"reasonDetail": "Used to handle an <input> element. Only updates the value and selection state."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/Inbox.js",
|
||||
|
@ -13603,6 +13565,27 @@
|
|||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-06-08T02:49:25.154Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/Input.js",
|
||||
"line": " const innerRef = react_1.useRef(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-07-14T00:50:58.330Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/Input.js",
|
||||
"line": " const valueOnKeydownRef = react_1.useRef(value);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-07-14T00:50:58.330Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/Input.js",
|
||||
"line": " const selectionStartOnKeydownRef = react_1.useRef(value.length);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-07-14T00:50:58.330Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-$(",
|
||||
"path": "ts/components/Intl.js",
|
||||
|
@ -13656,6 +13639,13 @@
|
|||
"updated": "2020-02-14T20:02:37.507Z",
|
||||
"reasonDetail": "Used only to set focus"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/ProfileEditor.js",
|
||||
"line": " const focusInputRef = react_1.useRef(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-07-14T00:50:58.330Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/SafetyNumberChangeDialog.js",
|
||||
|
|
|
@ -282,3 +282,13 @@ export function handleProfileKeyCredential(
|
|||
|
||||
return compatArrayToBase64(credentialArray);
|
||||
}
|
||||
|
||||
export function deriveProfileKeyCommitment(
|
||||
profileKeyBase64: string,
|
||||
uuid: string
|
||||
): string {
|
||||
const profileKeyArray = base64ToCompatArray(profileKeyBase64);
|
||||
const profileKey = new ProfileKey(profileKeyArray);
|
||||
|
||||
return compatArrayToBase64(profileKey.getCommitment(uuid).contents);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue