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",
|
"message": "Add a group photo",
|
||||||
"description": "The label for the avatar uploader when no group photo is selected"
|
"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": {
|
"AvatarInput--change-photo-label": {
|
||||||
"message": "Change photo",
|
"message": "Change photo",
|
||||||
"description": "The label for the avatar uploader when a photo is selected"
|
"description": "The label for the avatar uploader when a photo is selected"
|
||||||
|
@ -5642,5 +5646,75 @@
|
||||||
"MediaQualitySelector--high-quality-description": {
|
"MediaQualitySelector--high-quality-description": {
|
||||||
"message": "Slower, more data",
|
"message": "Slower, more data",
|
||||||
"description": "Description of high quality selector"
|
"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 {
|
&--active {
|
||||||
@include light-theme() {
|
@include light-theme() {
|
||||||
background: $color-gray-05;
|
background: $color-gray-05;
|
||||||
|
@ -9074,9 +9082,20 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-avatar-popup__profile {
|
.module-avatar-popup__profile {
|
||||||
|
@include button-reset();
|
||||||
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
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 {
|
.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/GroupDescription.scss';
|
||||||
@import './components/GroupDialog.scss';
|
@import './components/GroupDialog.scss';
|
||||||
@import './components/GroupInput.scss';
|
@import './components/GroupInput.scss';
|
||||||
|
@import './components/Input.scss';
|
||||||
@import './components/MediaQualitySelector.scss';
|
@import './components/MediaQualitySelector.scss';
|
||||||
@import './components/MessageAudio.scss';
|
@import './components/MessageAudio.scss';
|
||||||
@import './components/Modal.scss';
|
@import './components/Modal.scss';
|
||||||
|
@import './components/ProfileEditor.scss';
|
||||||
@import './components/SafetyNumberChangeDialog.scss';
|
@import './components/SafetyNumberChangeDialog.scss';
|
||||||
@import './components/SafetyNumberViewer.scss';
|
@import './components/SafetyNumberViewer.scss';
|
||||||
@import './components/SearchInput.scss';
|
@import './components/SearchInput.scss';
|
||||||
@import './components/SearchResultsLoadingFakeHeader.scss';
|
@import './components/SearchResultsLoadingFakeHeader.scss';
|
||||||
@import './components/SearchResultsLoadingFakeRow.scss';
|
@import './components/SearchResultsLoadingFakeRow.scss';
|
||||||
|
@import './components/Select.scss';
|
||||||
@import './components/Slider.scss';
|
@import './components/Slider.scss';
|
||||||
@import './components/Tabs.scss';
|
@import './components/Tabs.scss';
|
||||||
@import './components/Select.scss';
|
|
||||||
@import './components/TimelineWarning.scss';
|
@import './components/TimelineWarning.scss';
|
||||||
@import './components/TimelineWarnings.scss';
|
@import './components/TimelineWarnings.scss';
|
||||||
|
|
|
@ -18,12 +18,13 @@ import { LocalizerType } from '../types/Util';
|
||||||
import { Spinner } from './Spinner';
|
import { Spinner } from './Spinner';
|
||||||
import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer';
|
import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer';
|
||||||
|
|
||||||
type PropsType = {
|
export type PropsType = {
|
||||||
// This ID needs to be globally unique across the app.
|
// This ID needs to be globally unique across the app.
|
||||||
contextMenuId: string;
|
contextMenuId: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
onChange: (value: undefined | ArrayBuffer) => unknown;
|
onChange: (value: undefined | ArrayBuffer) => unknown;
|
||||||
|
type?: AvatarInputType;
|
||||||
value: undefined | ArrayBuffer;
|
value: undefined | ArrayBuffer;
|
||||||
variant?: AvatarInputVariant;
|
variant?: AvatarInputVariant;
|
||||||
};
|
};
|
||||||
|
@ -34,6 +35,11 @@ enum ImageStatus {
|
||||||
HasImage = 'has-image',
|
HasImage = 'has-image',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AvatarInputType {
|
||||||
|
Profile = 'Profile',
|
||||||
|
Group = 'Group',
|
||||||
|
}
|
||||||
|
|
||||||
export enum AvatarInputVariant {
|
export enum AvatarInputVariant {
|
||||||
Light = 'light',
|
Light = 'light',
|
||||||
Dark = 'dark',
|
Dark = 'dark',
|
||||||
|
@ -44,6 +50,7 @@ export const AvatarInput: FunctionComponent<PropsType> = ({
|
||||||
disabled,
|
disabled,
|
||||||
i18n,
|
i18n,
|
||||||
onChange,
|
onChange,
|
||||||
|
type,
|
||||||
value,
|
value,
|
||||||
variant = AvatarInputVariant.Light,
|
variant = AvatarInputVariant.Light,
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -96,9 +103,14 @@ export const AvatarInput: FunctionComponent<PropsType> = ({
|
||||||
};
|
};
|
||||||
}, [processingFile, onChange]);
|
}, [processingFile, onChange]);
|
||||||
|
|
||||||
const buttonLabel = value
|
let buttonLabel = i18n('AvatarInput--change-photo-label');
|
||||||
? i18n('AvatarInput--change-photo-label')
|
if (!value) {
|
||||||
: i18n('AvatarInput--no-photo-label--group');
|
if (type === AvatarInputType.Profile) {
|
||||||
|
buttonLabel = i18n('AvatarInput--no-photo-label--profile');
|
||||||
|
} else {
|
||||||
|
buttonLabel = i18n('AvatarInput--no-photo-label--group');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startUpload = () => {
|
const startUpload = () => {
|
||||||
const fileInput = fileInputRef.current;
|
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,
|
isMe: true,
|
||||||
name: text('name', overrideProps.name || ''),
|
name: text('name', overrideProps.name || ''),
|
||||||
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
|
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
|
||||||
|
onEditProfile: action('onEditProfile'),
|
||||||
onClick: action('onClick'),
|
onClick: action('onClick'),
|
||||||
onSetChatColor: action('onSetChatColor'),
|
onSetChatColor: action('onSetChatColor'),
|
||||||
onViewArchive: action('onViewArchive'),
|
onViewArchive: action('onViewArchive'),
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { LocalizerType } from '../types/Util';
|
||||||
export type Props = {
|
export type Props = {
|
||||||
readonly i18n: LocalizerType;
|
readonly i18n: LocalizerType;
|
||||||
|
|
||||||
|
onEditProfile: () => unknown;
|
||||||
onSetChatColor: () => unknown;
|
onSetChatColor: () => unknown;
|
||||||
onViewPreferences: () => unknown;
|
onViewPreferences: () => unknown;
|
||||||
onViewArchive: () => unknown;
|
onViewArchive: () => unknown;
|
||||||
|
@ -29,6 +30,7 @@ export const AvatarPopup = (props: Props): JSX.Element => {
|
||||||
profileName,
|
profileName,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
title,
|
title,
|
||||||
|
onEditProfile,
|
||||||
onSetChatColor,
|
onSetChatColor,
|
||||||
onViewPreferences,
|
onViewPreferences,
|
||||||
onViewArchive,
|
onViewArchive,
|
||||||
|
@ -44,7 +46,12 @@ export const AvatarPopup = (props: Props): JSX.Element => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={style} className="module-avatar-popup">
|
<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} />
|
<Avatar {...props} size={52} />
|
||||||
<div className="module-avatar-popup__profile__text">
|
<div className="module-avatar-popup__profile__text">
|
||||||
<div className="module-avatar-popup__profile__name">
|
<div className="module-avatar-popup__profile__name">
|
||||||
|
@ -56,11 +63,10 @@ export const AvatarPopup = (props: Props): JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
<hr className="module-avatar-popup__divider" />
|
<hr className="module-avatar-popup__divider" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={focusRef}
|
|
||||||
className="module-avatar-popup__item"
|
className="module-avatar-popup__item"
|
||||||
onClick={onViewPreferences}
|
onClick={onViewPreferences}
|
||||||
>
|
>
|
||||||
|
|
|
@ -309,7 +309,6 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
||||||
/>
|
/>
|
||||||
<div className="module-ForwardMessageModal__emoji">
|
<div className="module-ForwardMessageModal__emoji">
|
||||||
<EmojiButton
|
<EmojiButton
|
||||||
doSend={noop}
|
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onClose={focusTextEditInput}
|
onClose={focusTextEditInput}
|
||||||
onPickEmoji={insertEmoji}
|
onPickEmoji={insertEmoji}
|
||||||
|
|
|
@ -7,16 +7,28 @@ import { LocalizerType } from '../types/Util';
|
||||||
|
|
||||||
type PropsType = {
|
type PropsType = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
|
||||||
|
// ChatColorPicker
|
||||||
isChatColorEditorVisible: boolean;
|
isChatColorEditorVisible: boolean;
|
||||||
renderChatColorPicker: () => JSX.Element;
|
renderChatColorPicker: () => JSX.Element;
|
||||||
toggleChatColorEditor: () => unknown;
|
toggleChatColorEditor: () => unknown;
|
||||||
|
|
||||||
|
// ProfileEditor
|
||||||
|
isProfileEditorVisible: boolean;
|
||||||
|
renderProfileEditor: () => JSX.Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GlobalModalContainer = ({
|
export const GlobalModalContainer = ({
|
||||||
i18n,
|
i18n,
|
||||||
|
|
||||||
|
// ChatColorPicker
|
||||||
isChatColorEditorVisible,
|
isChatColorEditorVisible,
|
||||||
renderChatColorPicker,
|
renderChatColorPicker,
|
||||||
toggleChatColorEditor,
|
toggleChatColorEditor,
|
||||||
|
|
||||||
|
// ProfileEditor
|
||||||
|
isProfileEditorVisible,
|
||||||
|
renderProfileEditor,
|
||||||
}: PropsType): JSX.Element | null => {
|
}: PropsType): JSX.Element | null => {
|
||||||
if (isChatColorEditorVisible) {
|
if (isChatColorEditorVisible) {
|
||||||
return (
|
return (
|
||||||
|
@ -33,5 +45,9 @@ export const GlobalModalContainer = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isProfileEditorVisible) {
|
||||||
|
return renderProfileEditor();
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
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
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, {
|
import React, { forwardRef } from 'react';
|
||||||
ClipboardEvent,
|
|
||||||
forwardRef,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
|
import { Input } from './Input';
|
||||||
import { LocalizerType } from '../types/Util';
|
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 = {
|
type PropsType = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -24,128 +13,20 @@ type PropsType = {
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const GroupDescriptionInput = forwardRef<HTMLInputElement, PropsType>(
|
||||||
* 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>(
|
|
||||||
({ i18n, disabled = false, onChangeValue, value }, ref) => {
|
({ 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 (
|
return (
|
||||||
<>
|
<Input
|
||||||
<div className="module-GroupInput--container module-GroupInput__description--container">
|
disabled={disabled}
|
||||||
<textarea
|
expandable
|
||||||
className={classNames({
|
i18n={i18n}
|
||||||
'module-GroupInput': true,
|
onChange={onChangeValue}
|
||||||
'module-GroupInput__description': true,
|
placeholder={i18n('setGroupMetadata__group-description-placeholder')}
|
||||||
'module-GroupInput__description--large': isLarge,
|
maxGraphemeCount={256}
|
||||||
})}
|
ref={ref}
|
||||||
disabled={disabled}
|
value={value}
|
||||||
onChange={onChange}
|
whenToShowRemainingCount={150}
|
||||||
onKeyDown={onKeyDown}
|
/>
|
||||||
onPaste={onPaste}
|
|
||||||
placeholder={i18n(
|
|
||||||
'setGroupMetadata__group-description-placeholder'
|
|
||||||
)}
|
|
||||||
ref={multiRef<HTMLTextAreaElement>(ref, innerRef)}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
{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
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 { LocalizerType } from '../types/Util';
|
||||||
import { multiRef } from '../util/multiRef';
|
|
||||||
import * as grapheme from '../util/grapheme';
|
|
||||||
|
|
||||||
const MAX_GRAPHEME_COUNT = 32;
|
|
||||||
|
|
||||||
type PropsType = {
|
type PropsType = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -16,87 +13,18 @@ type PropsType = {
|
||||||
value: string;
|
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>(
|
export const GroupTitleInput = forwardRef<HTMLInputElement, PropsType>(
|
||||||
({ i18n, disabled = false, onChangeValue, value }, ref) => {
|
({ i18n, disabled = false, onChangeValue, value }, ref) => {
|
||||||
const innerRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const valueOnKeydownRef = useRef<string>(value);
|
|
||||||
const selectionStartOnKeydownRef = useRef<number>(value.length);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="module-GroupInput--container">
|
<Input
|
||||||
<input
|
disabled={disabled}
|
||||||
disabled={disabled}
|
i18n={i18n}
|
||||||
className="module-GroupInput"
|
onChange={onChangeValue}
|
||||||
onKeyDown={() => {
|
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
|
||||||
const inputEl = innerRef.current;
|
maxGraphemeCount={32}
|
||||||
if (!inputEl) {
|
ref={ref}
|
||||||
return;
|
value={value}
|
||||||
}
|
/>
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
|
|
||||||
ref={multiRef<HTMLInputElement>(ref, innerRef)}
|
|
||||||
type="text"
|
|
||||||
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'),
|
showArchivedConversations: action('showArchivedConversations'),
|
||||||
startComposing: action('startComposing'),
|
startComposing: action('startComposing'),
|
||||||
toggleChatColorEditor: action('toggleChatColorEditor'),
|
toggleChatColorEditor: action('toggleChatColorEditor'),
|
||||||
|
toggleProfileEditor: action('toggleProfileEditor'),
|
||||||
});
|
});
|
||||||
|
|
||||||
story.add('Basic', () => {
|
story.add('Basic', () => {
|
||||||
|
|
|
@ -65,6 +65,7 @@ export type PropsType = {
|
||||||
showArchivedConversations: () => void;
|
showArchivedConversations: () => void;
|
||||||
startComposing: () => void;
|
startComposing: () => void;
|
||||||
toggleChatColorEditor: () => void;
|
toggleChatColorEditor: () => void;
|
||||||
|
toggleProfileEditor: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateType = {
|
type StateType = {
|
||||||
|
@ -353,6 +354,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
||||||
searchTerm,
|
searchTerm,
|
||||||
showArchivedConversations,
|
showArchivedConversations,
|
||||||
toggleChatColorEditor,
|
toggleChatColorEditor,
|
||||||
|
toggleProfileEditor,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { showingAvatarPopup, popperRoot } = this.state;
|
const { showingAvatarPopup, popperRoot } = this.state;
|
||||||
|
|
||||||
|
@ -410,6 +412,10 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
||||||
size={28}
|
size={28}
|
||||||
// See the comment above about `sharedGroupNames`.
|
// See the comment above about `sharedGroupNames`.
|
||||||
sharedGroupNames={[]}
|
sharedGroupNames={[]}
|
||||||
|
onEditProfile={() => {
|
||||||
|
toggleProfileEditor();
|
||||||
|
this.hideAvatarPopup();
|
||||||
|
}}
|
||||||
onSetChatColor={() => {
|
onSetChatColor={() => {
|
||||||
toggleChatColorEditor();
|
toggleChatColorEditor();
|
||||||
this.hideAvatarPopup();
|
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, {
|
import React, {
|
||||||
FormEventHandler,
|
FormEventHandler,
|
||||||
FunctionComponent,
|
FunctionComponent,
|
||||||
useEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { noop } from 'lodash';
|
|
||||||
|
|
||||||
import { LocalizerType } from '../../../types/Util';
|
import { LocalizerType } from '../../../types/Util';
|
||||||
import { Modal } from '../../Modal';
|
import { Modal } from '../../Modal';
|
||||||
import { AvatarInput, AvatarInputVariant } from '../../AvatarInput';
|
import { AvatarInputContainer } from '../../AvatarInputContainer';
|
||||||
|
import { AvatarInputVariant } from '../../AvatarInput';
|
||||||
import { Button, ButtonVariant } from '../../Button';
|
import { Button, ButtonVariant } from '../../Button';
|
||||||
import { Spinner } from '../../Spinner';
|
import { Spinner } from '../../Spinner';
|
||||||
import { GroupDescriptionInput } from '../../GroupDescriptionInput';
|
import { GroupDescriptionInput } from '../../GroupDescriptionInput';
|
||||||
import { GroupTitleInput } from '../../GroupTitleInput';
|
import { GroupTitleInput } from '../../GroupTitleInput';
|
||||||
import * as log from '../../../logging/log';
|
|
||||||
import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer';
|
|
||||||
import { RequestState } from './util';
|
import { RequestState } from './util';
|
||||||
|
|
||||||
const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
|
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 =
|
const hasChangedExternally =
|
||||||
startingAvatarPathRef.current !== externalAvatarPath ||
|
startingAvatarPathRef.current !== externalAvatarPath ||
|
||||||
startingTitleRef.current !== externalTitle;
|
startingTitleRef.current !== externalTitle;
|
||||||
|
@ -154,15 +122,18 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className="module-EditConversationAttributesModal"
|
className="module-EditConversationAttributesModal"
|
||||||
>
|
>
|
||||||
<AvatarInput
|
<AvatarInputContainer
|
||||||
|
avatarPath={externalAvatarPath}
|
||||||
contextMenuId="edit conversation attributes avatar input"
|
contextMenuId="edit conversation attributes avatar input"
|
||||||
disabled={isRequestActive}
|
disabled={isRequestActive}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onChange={newAvatar => {
|
onAvatarChanged={newAvatar => {
|
||||||
setAvatar(newAvatar);
|
setAvatar(newAvatar);
|
||||||
setHasAvatarChanged(true);
|
setHasAvatarChanged(true);
|
||||||
}}
|
}}
|
||||||
value={avatar}
|
onAvatarLoaded={loadedAvatar => {
|
||||||
|
setAvatar(loadedAvatar);
|
||||||
|
}}
|
||||||
variant={AvatarInputVariant.Dark}
|
variant={AvatarInputVariant.Dark}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -217,25 +188,3 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
||||||
</Modal>
|
</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 { get, noop } from 'lodash';
|
||||||
import { Manager, Popper, Reference } from 'react-popper';
|
import { Manager, Popper, Reference } from 'react-popper';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Emoji } from './Emoji';
|
||||||
import { EmojiPicker, Props as EmojiPickerProps } from './EmojiPicker';
|
import { EmojiPicker, Props as EmojiPickerProps } from './EmojiPicker';
|
||||||
import { LocalizerType } from '../../types/Util';
|
import { LocalizerType } from '../../types/Util';
|
||||||
|
|
||||||
export type OwnProps = {
|
export type OwnProps = {
|
||||||
|
readonly closeOnPick?: boolean;
|
||||||
|
readonly emoji?: string;
|
||||||
readonly i18n: LocalizerType;
|
readonly i18n: LocalizerType;
|
||||||
readonly onClose?: () => unknown;
|
readonly onClose?: () => unknown;
|
||||||
};
|
};
|
||||||
|
@ -22,6 +25,8 @@ export type Props = OwnProps &
|
||||||
|
|
||||||
export const EmojiButton = React.memo(
|
export const EmojiButton = React.memo(
|
||||||
({
|
({
|
||||||
|
closeOnPick,
|
||||||
|
emoji,
|
||||||
i18n,
|
i18n,
|
||||||
doSend,
|
doSend,
|
||||||
onClose,
|
onClose,
|
||||||
|
@ -114,9 +119,12 @@ export const EmojiButton = React.memo(
|
||||||
className={classNames({
|
className={classNames({
|
||||||
'module-emoji-button__button': true,
|
'module-emoji-button__button': true,
|
||||||
'module-emoji-button__button--active': open,
|
'module-emoji-button__button--active': open,
|
||||||
|
'module-emoji-button__button--has-emoji': Boolean(emoji),
|
||||||
})}
|
})}
|
||||||
aria-label={i18n('EmojiButton__label')}
|
aria-label={i18n('EmojiButton__label')}
|
||||||
/>
|
>
|
||||||
|
{emoji && <Emoji emoji={emoji} size={24} />}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</Reference>
|
</Reference>
|
||||||
{open && popperRoot
|
{open && popperRoot
|
||||||
|
@ -127,7 +135,12 @@ export const EmojiButton = React.memo(
|
||||||
ref={ref}
|
ref={ref}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
style={style}
|
style={style}
|
||||||
onPickEmoji={onPickEmoji}
|
onPickEmoji={ev => {
|
||||||
|
onPickEmoji(ev);
|
||||||
|
if (closeOnPick) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
doSend={doSend}
|
doSend={doSend}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
skinTone={skinTone}
|
skinTone={skinTone}
|
||||||
|
|
|
@ -1366,6 +1366,8 @@ export class ConversationModel extends window.Backbone
|
||||||
e164: this.get('e164'),
|
e164: this.get('e164'),
|
||||||
|
|
||||||
about: this.getAboutText(),
|
about: this.getAboutText(),
|
||||||
|
aboutText: this.get('about'),
|
||||||
|
aboutEmoji: this.get('aboutEmoji'),
|
||||||
acceptedMessageRequest: this.getAccepted(),
|
acceptedMessageRequest: this.getAccepted(),
|
||||||
activeAt: this.get('active_at')!,
|
activeAt: this.get('active_at')!,
|
||||||
areWePending: Boolean(
|
areWePending: Boolean(
|
||||||
|
@ -1378,6 +1380,7 @@ export class ConversationModel extends window.Backbone
|
||||||
canChangeTimer: this.canChangeTimer(),
|
canChangeTimer: this.canChangeTimer(),
|
||||||
canEditGroupInfo: this.canEditGroupInfo(),
|
canEditGroupInfo: this.canEditGroupInfo(),
|
||||||
avatarPath: this.getAbsoluteAvatarPath(),
|
avatarPath: this.getAbsoluteAvatarPath(),
|
||||||
|
avatarHash: this.getAvatarHash(),
|
||||||
unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(),
|
unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(),
|
||||||
color,
|
color,
|
||||||
conversationColor: this.getConversationColor(),
|
conversationColor: this.getConversationColor(),
|
||||||
|
@ -1387,6 +1390,7 @@ export class ConversationModel extends window.Backbone
|
||||||
draftBodyRanges,
|
draftBodyRanges,
|
||||||
draftPreview,
|
draftPreview,
|
||||||
draftText,
|
draftText,
|
||||||
|
familyName: this.get('profileFamilyName'),
|
||||||
firstName: this.get('profileName')!,
|
firstName: this.get('profileName')!,
|
||||||
groupDescription: this.get('description'),
|
groupDescription: this.get('description'),
|
||||||
groupVersion,
|
groupVersion,
|
||||||
|
@ -1417,6 +1421,7 @@ export class ConversationModel extends window.Backbone
|
||||||
messageCount: this.get('messageCount') || 0,
|
messageCount: this.get('messageCount') || 0,
|
||||||
pendingMemberships: this.getPendingMemberships(),
|
pendingMemberships: this.getPendingMemberships(),
|
||||||
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
|
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
|
||||||
|
profileKey: this.get('profileKey'),
|
||||||
messageRequestsEnabled,
|
messageRequestsEnabled,
|
||||||
accessControlAddFromInviteLink: this.get('accessControl')
|
accessControlAddFromInviteLink: this.get('accessControl')
|
||||||
?.addFromInviteLink,
|
?.addFromInviteLink,
|
||||||
|
@ -4522,6 +4527,10 @@ export class ConversationModel extends window.Backbone
|
||||||
c.unset('aboutEmoji');
|
c.unset('aboutEmoji');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (profile.paymentAddress && isMe(c.attributes)) {
|
||||||
|
window.storage.put('paymentAddress', profile.paymentAddress);
|
||||||
|
}
|
||||||
|
|
||||||
if (profile.capabilities) {
|
if (profile.capabilities) {
|
||||||
c.set({ capabilities: profile.capabilities });
|
c.set({ capabilities: profile.capabilities });
|
||||||
} else {
|
} else {
|
||||||
|
@ -4896,6 +4905,13 @@ export class ConversationModel extends window.Backbone
|
||||||
return avatar?.path || undefined;
|
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 {
|
getAbsoluteAvatarPath(): string | undefined {
|
||||||
const avatarPath = this.getAvatarPath();
|
const avatarPath = this.getAvatarPath();
|
||||||
return avatarPath ? getAbsoluteAttachmentPath(avatarPath) : undefined;
|
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 { StateType as RootStateType } from '../reducer';
|
||||||
import * as groups from '../../groups';
|
import * as groups from '../../groups';
|
||||||
|
import * as log from '../../logging/log';
|
||||||
import { calling } from '../../services/calling';
|
import { calling } from '../../services/calling';
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
import { assert } from '../../util/assert';
|
import { assert } from '../../util/assert';
|
||||||
import * as universalExpireTimer from '../../util/universalExpireTimer';
|
import * as universalExpireTimer from '../../util/universalExpireTimer';
|
||||||
import { trigger } from '../../shims/events';
|
import { trigger } from '../../shims/events';
|
||||||
|
import {
|
||||||
|
TOGGLE_PROFILE_EDITOR_ERROR,
|
||||||
|
ToggleProfileEditorErrorActionType,
|
||||||
|
} from './globalModals';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AvatarColorType,
|
AvatarColorType,
|
||||||
|
@ -45,6 +50,8 @@ import {
|
||||||
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
|
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
|
||||||
import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
||||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||||
|
import { writeProfile } from '../../services/writeProfile';
|
||||||
|
import { getMe } from '../selectors/conversations';
|
||||||
|
|
||||||
import { NoopActionType } from './noop';
|
import { NoopActionType } from './noop';
|
||||||
|
|
||||||
|
@ -72,10 +79,14 @@ export type ConversationType = {
|
||||||
uuid?: string;
|
uuid?: string;
|
||||||
e164?: string;
|
e164?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
familyName?: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
profileName?: string;
|
profileName?: string;
|
||||||
about?: string;
|
about?: string;
|
||||||
|
aboutText?: string;
|
||||||
|
aboutEmoji?: string;
|
||||||
avatarPath?: string;
|
avatarPath?: string;
|
||||||
|
avatarHash?: string;
|
||||||
unblurredAvatarPath?: string;
|
unblurredAvatarPath?: string;
|
||||||
areWeAdmin?: boolean;
|
areWeAdmin?: boolean;
|
||||||
areWePending?: boolean;
|
areWePending?: boolean;
|
||||||
|
@ -157,7 +168,12 @@ export type ConversationType = {
|
||||||
secretParams?: string;
|
secretParams?: string;
|
||||||
publicParams?: string;
|
publicParams?: string;
|
||||||
acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle;
|
acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle;
|
||||||
|
profileKey?: string;
|
||||||
};
|
};
|
||||||
|
export type ProfileDataType = {
|
||||||
|
firstName: string;
|
||||||
|
} & Pick<ConversationType, 'aboutEmoji' | 'aboutText' | 'familyName'>;
|
||||||
|
|
||||||
export type ConversationLookupType = {
|
export type ConversationLookupType = {
|
||||||
[key: string]: ConversationType;
|
[key: string]: ConversationType;
|
||||||
};
|
};
|
||||||
|
@ -673,6 +689,7 @@ export const actions = {
|
||||||
messagesAdded,
|
messagesAdded,
|
||||||
messageSizeChanged,
|
messageSizeChanged,
|
||||||
messagesReset,
|
messagesReset,
|
||||||
|
myProfileChanged,
|
||||||
openConversationExternal,
|
openConversationExternal,
|
||||||
openConversationInternal,
|
openConversationInternal,
|
||||||
removeAllConversations,
|
removeAllConversations,
|
||||||
|
@ -704,6 +721,41 @@ export const actions = {
|
||||||
toggleConversationInChooseMembers,
|
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(
|
function removeCustomColorOnConversations(
|
||||||
colorId: string
|
colorId: string
|
||||||
): ThunkAction<void, RootStateType, unknown, CustomColorRemovedActionType> {
|
): ThunkAction<void, RootStateType, unknown, CustomColorRemovedActionType> {
|
||||||
|
|
|
@ -5,33 +5,61 @@
|
||||||
|
|
||||||
export type GlobalModalsStateType = {
|
export type GlobalModalsStateType = {
|
||||||
readonly isChatColorEditorVisible: boolean;
|
readonly isChatColorEditorVisible: boolean;
|
||||||
|
readonly isProfileEditorVisible: boolean;
|
||||||
|
readonly profileEditorHasError: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
const TOGGLE_CHAT_COLOR_EDITOR = 'globalModals/TOGGLE_CHAT_COLOR_EDITOR';
|
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 ToggleChatColorEditorActionType = {
|
||||||
type: typeof TOGGLE_CHAT_COLOR_EDITOR;
|
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
|
// Action Creators
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
toggleChatColorEditor,
|
toggleChatColorEditor,
|
||||||
|
toggleProfileEditor,
|
||||||
|
toggleProfileEditorHasError,
|
||||||
};
|
};
|
||||||
|
|
||||||
function toggleChatColorEditor(): ToggleChatColorEditorActionType {
|
function toggleChatColorEditor(): ToggleChatColorEditorActionType {
|
||||||
return { type: TOGGLE_CHAT_COLOR_EDITOR };
|
return { type: TOGGLE_CHAT_COLOR_EDITOR };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleProfileEditor(): ToggleProfileEditorActionType {
|
||||||
|
return { type: TOGGLE_PROFILE_EDITOR };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProfileEditorHasError(): ToggleProfileEditorErrorActionType {
|
||||||
|
return { type: TOGGLE_PROFILE_EDITOR_ERROR };
|
||||||
|
}
|
||||||
|
|
||||||
// Reducer
|
// Reducer
|
||||||
|
|
||||||
export function getEmptyState(): GlobalModalsStateType {
|
export function getEmptyState(): GlobalModalsStateType {
|
||||||
return {
|
return {
|
||||||
isChatColorEditorVisible: false,
|
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;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,16 +8,28 @@ import { GlobalModalContainer } from '../../components/GlobalModalContainer';
|
||||||
import { StateType } from '../reducer';
|
import { StateType } from '../reducer';
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
import { SmartChatColorPicker } from './ChatColorPicker';
|
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 {
|
function renderChatColorPicker(): JSX.Element {
|
||||||
return <SmartChatColorPicker />;
|
return <SmartChatColorPicker />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderProfileEditor(): JSX.Element {
|
||||||
|
return <FilteredSmartProfileEditorModal />;
|
||||||
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state: StateType) => {
|
const mapStateToProps = (state: StateType) => {
|
||||||
return {
|
return {
|
||||||
...state.globalModals,
|
...state.globalModals,
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
renderChatColorPicker,
|
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',
|
'Jimenez',
|
||||||
];
|
];
|
||||||
|
|
||||||
const getFirstName = (): string => sample(FIRST_NAMES) || 'Test';
|
export const getFirstName = (): string => sample(FIRST_NAMES) || 'Test';
|
||||||
const getLastName = (): string => sample(LAST_NAMES) || 'Test';
|
export const getLastName = (): string => sample(LAST_NAMES) || 'Test';
|
||||||
|
|
||||||
export function getDefaultConversation(
|
export function getDefaultConversation(
|
||||||
overrideProps: Partial<ConversationType> = {}
|
overrideProps: Partial<ConversationType> = {}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { assert } from 'chai';
|
||||||
|
|
||||||
import * as Curve from '../Curve';
|
import * as Curve from '../Curve';
|
||||||
import * as Crypto from '../Crypto';
|
import * as Crypto from '../Crypto';
|
||||||
import TSCrypto from '../textsecure/Crypto';
|
import TSCrypto, { PaddedLengths } from '../textsecure/Crypto';
|
||||||
|
|
||||||
describe('Crypto', () => {
|
describe('Crypto', () => {
|
||||||
describe('encrypting and decrypting profile data', () => {
|
describe('encrypting and decrypting profile data', () => {
|
||||||
|
@ -16,8 +16,12 @@ describe('Crypto', () => {
|
||||||
const buffer = Crypto.bytesFromString(name);
|
const buffer = Crypto.bytesFromString(name);
|
||||||
const key = Crypto.getRandomBytes(32);
|
const key = Crypto.getRandomBytes(32);
|
||||||
|
|
||||||
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
|
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
|
||||||
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
|
buffer,
|
||||||
|
key,
|
||||||
|
PaddedLengths.Name
|
||||||
|
);
|
||||||
|
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
|
||||||
|
|
||||||
const { given, family } = await TSCrypto.decryptProfileName(
|
const { given, family } = await TSCrypto.decryptProfileName(
|
||||||
Crypto.arrayBufferToBase64(encrypted),
|
Crypto.arrayBufferToBase64(encrypted),
|
||||||
|
@ -32,8 +36,12 @@ describe('Crypto', () => {
|
||||||
const buffer = Crypto.bytesFromString(name);
|
const buffer = Crypto.bytesFromString(name);
|
||||||
const key = Crypto.getRandomBytes(32);
|
const key = Crypto.getRandomBytes(32);
|
||||||
|
|
||||||
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
|
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
|
||||||
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
|
buffer,
|
||||||
|
key,
|
||||||
|
PaddedLengths.Name
|
||||||
|
);
|
||||||
|
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
|
||||||
const { given, family } = await TSCrypto.decryptProfileName(
|
const { given, family } = await TSCrypto.decryptProfileName(
|
||||||
Crypto.arrayBufferToBase64(encrypted),
|
Crypto.arrayBufferToBase64(encrypted),
|
||||||
key
|
key
|
||||||
|
@ -49,8 +57,12 @@ describe('Crypto', () => {
|
||||||
const buffer = Crypto.bytesFromString(name);
|
const buffer = Crypto.bytesFromString(name);
|
||||||
const key = Crypto.getRandomBytes(32);
|
const key = Crypto.getRandomBytes(32);
|
||||||
|
|
||||||
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
|
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
|
||||||
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
|
buffer,
|
||||||
|
key,
|
||||||
|
PaddedLengths.Name
|
||||||
|
);
|
||||||
|
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
|
||||||
const { given, family } = await TSCrypto.decryptProfileName(
|
const { given, family } = await TSCrypto.decryptProfileName(
|
||||||
Crypto.arrayBufferToBase64(encrypted),
|
Crypto.arrayBufferToBase64(encrypted),
|
||||||
key
|
key
|
||||||
|
@ -70,8 +82,12 @@ describe('Crypto', () => {
|
||||||
const buffer = Crypto.bytesFromString(name);
|
const buffer = Crypto.bytesFromString(name);
|
||||||
const key = Crypto.getRandomBytes(32);
|
const key = Crypto.getRandomBytes(32);
|
||||||
|
|
||||||
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
|
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
|
||||||
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
|
buffer,
|
||||||
|
key,
|
||||||
|
PaddedLengths.Name
|
||||||
|
);
|
||||||
|
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
|
||||||
const { given, family } = await TSCrypto.decryptProfileName(
|
const { given, family } = await TSCrypto.decryptProfileName(
|
||||||
Crypto.arrayBufferToBase64(encrypted),
|
Crypto.arrayBufferToBase64(encrypted),
|
||||||
key
|
key
|
||||||
|
@ -84,8 +100,12 @@ describe('Crypto', () => {
|
||||||
const name = Crypto.bytesFromString('');
|
const name = Crypto.bytesFromString('');
|
||||||
const key = Crypto.getRandomBytes(32);
|
const key = Crypto.getRandomBytes(32);
|
||||||
|
|
||||||
const encrypted = await TSCrypto.encryptProfileName(name, key);
|
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
|
||||||
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
|
name,
|
||||||
|
key,
|
||||||
|
PaddedLengths.Name
|
||||||
|
);
|
||||||
|
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
|
||||||
|
|
||||||
const { given, family } = await TSCrypto.decryptProfileName(
|
const { given, family } = await TSCrypto.decryptProfileName(
|
||||||
Crypto.arrayBufferToBase64(encrypted),
|
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_IV_LENGTH = 12; // bytes
|
||||||
const PROFILE_KEY_LENGTH = 32; // bytes
|
const PROFILE_KEY_LENGTH = 32; // bytes
|
||||||
const PROFILE_TAG_LENGTH = 128; // bits
|
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 = {
|
type EncryptedAttachment = {
|
||||||
ciphertext: ArrayBuffer;
|
ciphertext: ArrayBuffer;
|
||||||
|
@ -324,13 +331,20 @@ const Crypto = {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async encryptProfileName(
|
async encryptProfileItemWithPadding(
|
||||||
name: ArrayBuffer,
|
item: ArrayBuffer,
|
||||||
key: ArrayBuffer
|
profileKey: ArrayBuffer,
|
||||||
|
paddedLengths: typeof PaddedLengths[keyof typeof PaddedLengths]
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
const padded = new Uint8Array(PROFILE_NAME_PADDED_LENGTH);
|
const paddedLength = paddedLengths.find(
|
||||||
padded.set(new Uint8Array(name));
|
(length: number) => item.byteLength <= length
|
||||||
return Crypto.encryptProfile(padded.buffer as ArrayBuffer, key);
|
);
|
||||||
|
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(
|
async decryptProfileName(
|
||||||
|
|
|
@ -20,12 +20,14 @@ import { assert } from '../util/assert';
|
||||||
import { parseIntOrThrow } from '../util/parseIntOrThrow';
|
import { parseIntOrThrow } from '../util/parseIntOrThrow';
|
||||||
import { SenderKeys } from '../LibSignalStores';
|
import { SenderKeys } from '../LibSignalStores';
|
||||||
import {
|
import {
|
||||||
|
ChallengeType,
|
||||||
GroupCredentialsType,
|
GroupCredentialsType,
|
||||||
GroupLogResponseType,
|
GroupLogResponseType,
|
||||||
ProxiedRequestOptionsType,
|
|
||||||
ChallengeType,
|
|
||||||
WebAPIType,
|
|
||||||
MultiRecipient200ResponseType,
|
MultiRecipient200ResponseType,
|
||||||
|
ProfileRequestDataType,
|
||||||
|
ProxiedRequestOptionsType,
|
||||||
|
UploadAvatarHeadersType,
|
||||||
|
WebAPIType,
|
||||||
} from './WebAPI';
|
} from './WebAPI';
|
||||||
import createTaskWithTimeout from './TaskWithTimeout';
|
import createTaskWithTimeout from './TaskWithTimeout';
|
||||||
import OutgoingMessage, {
|
import OutgoingMessage, {
|
||||||
|
@ -2184,4 +2186,17 @@ export default class MessageSender {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.server.sendChallengeResponse(challengeResponse);
|
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;
|
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 = {
|
export type WebAPIType = {
|
||||||
confirmCode: (
|
confirmCode: (
|
||||||
number: string,
|
number: string,
|
||||||
|
@ -1017,6 +1040,9 @@ export type WebAPIType = {
|
||||||
) => Promise<Proto.IGroupChange>;
|
) => Promise<Proto.IGroupChange>;
|
||||||
modifyStorageRecords: MessageSender['modifyStorageRecords'];
|
modifyStorageRecords: MessageSender['modifyStorageRecords'];
|
||||||
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
|
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
|
||||||
|
putProfile: (
|
||||||
|
jsonData: ProfileRequestDataType
|
||||||
|
) => Promise<UploadAvatarHeadersType | undefined>;
|
||||||
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
|
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
|
||||||
putStickers: (
|
putStickers: (
|
||||||
encryptedManifest: ArrayBuffer,
|
encryptedManifest: ArrayBuffer,
|
||||||
|
@ -1050,6 +1076,10 @@ export type WebAPIType = {
|
||||||
) => Promise<MultiRecipient200ResponseType>;
|
) => Promise<MultiRecipient200ResponseType>;
|
||||||
setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise<void>;
|
setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise<void>;
|
||||||
updateDeviceName: (deviceName: string) => Promise<void>;
|
updateDeviceName: (deviceName: string) => Promise<void>;
|
||||||
|
uploadAvatar: (
|
||||||
|
uploadAvatarRequestHeaders: UploadAvatarHeadersType,
|
||||||
|
avatarData: ArrayBuffer
|
||||||
|
) => Promise<string>;
|
||||||
uploadGroupAvatar: (
|
uploadGroupAvatar: (
|
||||||
avatarData: Uint8Array,
|
avatarData: Uint8Array,
|
||||||
options: GroupCredentialsType
|
options: GroupCredentialsType
|
||||||
|
@ -1209,6 +1239,7 @@ export function initialize({
|
||||||
modifyGroup,
|
modifyGroup,
|
||||||
modifyStorageRecords,
|
modifyStorageRecords,
|
||||||
putAttachment,
|
putAttachment,
|
||||||
|
putProfile,
|
||||||
putStickers,
|
putStickers,
|
||||||
registerCapabilities,
|
registerCapabilities,
|
||||||
registerKeys,
|
registerKeys,
|
||||||
|
@ -1222,6 +1253,7 @@ export function initialize({
|
||||||
sendWithSenderKey,
|
sendWithSenderKey,
|
||||||
setSignedPreKey,
|
setSignedPreKey,
|
||||||
updateDeviceName,
|
updateDeviceName,
|
||||||
|
uploadAvatar,
|
||||||
uploadGroupAvatar,
|
uploadGroupAvatar,
|
||||||
whoami,
|
whoami,
|
||||||
sendChallengeResponse,
|
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(
|
async function getProfileUnauth(
|
||||||
identifier: string,
|
identifier: string,
|
||||||
options: {
|
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(
|
async function uploadGroupAvatar(
|
||||||
avatarData: Uint8Array,
|
avatarData: Uint8Array,
|
||||||
options: GroupCredentialsType
|
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;
|
'indexeddb-delete-needed': boolean;
|
||||||
senderCertificate: SerializedCertificateType;
|
senderCertificate: SerializedCertificateType;
|
||||||
senderCertificateNoE164: SerializedCertificateType;
|
senderCertificateNoE164: SerializedCertificateType;
|
||||||
|
paymentAddress: string;
|
||||||
|
|
||||||
// Deprecated
|
// Deprecated
|
||||||
senderCertificateWithUuid: never;
|
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",
|
"updated": "2021-03-01T18:34:36.638Z",
|
||||||
"reasonDetail": "Used to reference popup menu"
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/AvatarPopup.js",
|
"path": "ts/components/AvatarPopup.js",
|
||||||
|
@ -13544,51 +13551,6 @@
|
||||||
"updated": "2021-06-17T20:46:02.342Z",
|
"updated": "2021-06-17T20:46:02.342Z",
|
||||||
"reasonDetail": "Doesn't reference the DOM."
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/Inbox.js",
|
"path": "ts/components/Inbox.js",
|
||||||
|
@ -13603,6 +13565,27 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-06-08T02:49:25.154Z"
|
"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-$(",
|
"rule": "jQuery-$(",
|
||||||
"path": "ts/components/Intl.js",
|
"path": "ts/components/Intl.js",
|
||||||
|
@ -13656,6 +13639,13 @@
|
||||||
"updated": "2020-02-14T20:02:37.507Z",
|
"updated": "2020-02-14T20:02:37.507Z",
|
||||||
"reasonDetail": "Used only to set focus"
|
"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",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/SafetyNumberChangeDialog.js",
|
"path": "ts/components/SafetyNumberChangeDialog.js",
|
||||||
|
|
|
@ -282,3 +282,13 @@ export function handleProfileKeyCredential(
|
||||||
|
|
||||||
return compatArrayToBase64(credentialArray);
|
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…
Add table
Add a link
Reference in a new issue