Edit profile

This commit is contained in:
Josh Perez 2021-07-19 15:26:06 -04:00 committed by GitHub
parent f14c426170
commit cd35a29638
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 2124 additions and 356 deletions

View File

@ -5161,6 +5161,10 @@
"message": "Add a group photo",
"description": "The label for the avatar uploader when no group photo is selected"
},
"AvatarInput--no-photo-label--profile": {
"message": "Add a photo",
"description": "The label for the avatar uploader when no profile photo is selected"
},
"AvatarInput--change-photo-label": {
"message": "Change photo",
"description": "The label for the avatar uploader when a photo is selected"
@ -5642,5 +5646,75 @@
"MediaQualitySelector--high-quality-description": {
"message": "Slower, more data",
"description": "Description of high quality selector"
},
"ProfileEditor--about": {
"message": "About",
"description": "Default text for about field"
},
"ProfileEditor--about-placeholder": {
"message": "Write something about yourself...",
"description": "Placeholder text for about input field"
},
"ProfileEditor--first-name": {
"message": "First Name (Required)",
"description": "Placeholder text for first name field"
},
"ProfileEditor--last-name": {
"message": "Last Name (Optional)",
"description": "Placeholder text for last name field"
},
"ProfileEditor--discard": {
"message": "Would you like to discard these changes?",
"description": "ConfirmationDialog text for discarding changes"
},
"ProfileEditor--info": {
"message": "Your profile is encrypted. Your profile and changes to it will be visible to your contacts and when you start or accept new chats. $learnMore$",
"description": "Information shown at the bottom of the profile editor section",
"placeholders": {
"learnMore": {
"content": "$1",
"example": "Learn More."
}
}
},
"ProfileEditor--learnMore": {
"message": "Learn More",
"description": "Text that links to a support article"
},
"Bio--speak-freely": {
"message": "Speak Freely",
"description": "A default bio option"
},
"Bio--encrypted": {
"message": "Encrypted",
"description": "A default bio option"
},
"Bio--free-to-chat": {
"message": "Free to chat",
"description": "A default bio option"
},
"Bio--coffee-lover": {
"message": "Coffee lover",
"description": "A default bio option"
},
"Bio--taking-break": {
"message": "Taking a break",
"description": "A default bio option"
},
"ProfileEditorModal--profile": {
"message": "Profile",
"description": "Title for profile editing"
},
"ProfileEditorModal--name": {
"message": "Your Name",
"description": "Title for editing your name"
},
"ProfileEditorModal--about": {
"message": "About",
"description": "Title for about editing"
},
"ProfileEditorModal--error": {
"message": "Your profile could not be updated. Please try again.",
"description": "Error message when something goes wrong updating your profile."
}
}

View File

@ -8430,6 +8430,14 @@ button.module-image__border-overlay:focus {
}
}
&--has-emoji {
opacity: 1;
&::after {
display: none;
}
}
&--active {
@include light-theme() {
background: $color-gray-05;
@ -9074,9 +9082,20 @@ button.module-image__border-overlay:focus {
}
.module-avatar-popup__profile {
@include button-reset();
align-items: center;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
&:hover {
@include light-theme {
background-color: $color-gray-15;
}
@include dark-theme {
background-color: $color-gray-60;
}
}
}
.module-avatar-popup__profile {

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

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

View File

@ -50,16 +50,18 @@
@import './components/GroupDescription.scss';
@import './components/GroupDialog.scss';
@import './components/GroupInput.scss';
@import './components/Input.scss';
@import './components/MediaQualitySelector.scss';
@import './components/MessageAudio.scss';
@import './components/Modal.scss';
@import './components/ProfileEditor.scss';
@import './components/SafetyNumberChangeDialog.scss';
@import './components/SafetyNumberViewer.scss';
@import './components/SearchInput.scss';
@import './components/SearchResultsLoadingFakeHeader.scss';
@import './components/SearchResultsLoadingFakeRow.scss';
@import './components/Select.scss';
@import './components/Slider.scss';
@import './components/Tabs.scss';
@import './components/Select.scss';
@import './components/TimelineWarning.scss';
@import './components/TimelineWarnings.scss';

View File

@ -18,12 +18,13 @@ import { LocalizerType } from '../types/Util';
import { Spinner } from './Spinner';
import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer';
type PropsType = {
export type PropsType = {
// This ID needs to be globally unique across the app.
contextMenuId: string;
disabled?: boolean;
i18n: LocalizerType;
onChange: (value: undefined | ArrayBuffer) => unknown;
type?: AvatarInputType;
value: undefined | ArrayBuffer;
variant?: AvatarInputVariant;
};
@ -34,6 +35,11 @@ enum ImageStatus {
HasImage = 'has-image',
}
export enum AvatarInputType {
Profile = 'Profile',
Group = 'Group',
}
export enum AvatarInputVariant {
Light = 'light',
Dark = 'dark',
@ -44,6 +50,7 @@ export const AvatarInput: FunctionComponent<PropsType> = ({
disabled,
i18n,
onChange,
type,
value,
variant = AvatarInputVariant.Light,
}) => {
@ -96,9 +103,14 @@ export const AvatarInput: FunctionComponent<PropsType> = ({
};
}, [processingFile, onChange]);
const buttonLabel = value
? i18n('AvatarInput--change-photo-label')
: i18n('AvatarInput--no-photo-label--group');
let buttonLabel = i18n('AvatarInput--change-photo-label');
if (!value) {
if (type === AvatarInputType.Profile) {
buttonLabel = i18n('AvatarInput--no-photo-label--profile');
} else {
buttonLabel = i18n('AvatarInput--no-photo-label--group');
}
}
const startUpload = () => {
const fileInput = fileInputRef.current;

View 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}
/>
));

View 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}
/>
);
};

View File

@ -40,6 +40,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isMe: true,
name: text('name', overrideProps.name || ''),
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
onEditProfile: action('onEditProfile'),
onClick: action('onClick'),
onSetChatColor: action('onSetChatColor'),
onViewArchive: action('onViewArchive'),

View File

@ -12,6 +12,7 @@ import { LocalizerType } from '../types/Util';
export type Props = {
readonly i18n: LocalizerType;
onEditProfile: () => unknown;
onSetChatColor: () => unknown;
onViewPreferences: () => unknown;
onViewArchive: () => unknown;
@ -29,6 +30,7 @@ export const AvatarPopup = (props: Props): JSX.Element => {
profileName,
phoneNumber,
title,
onEditProfile,
onSetChatColor,
onViewPreferences,
onViewArchive,
@ -44,7 +46,12 @@ export const AvatarPopup = (props: Props): JSX.Element => {
return (
<div style={style} className="module-avatar-popup">
<div className="module-avatar-popup__profile">
<button
className="module-avatar-popup__profile"
onClick={onEditProfile}
ref={focusRef}
type="button"
>
<Avatar {...props} size={52} />
<div className="module-avatar-popup__profile__text">
<div className="module-avatar-popup__profile__name">
@ -56,11 +63,10 @@ export const AvatarPopup = (props: Props): JSX.Element => {
</div>
) : null}
</div>
</div>
</button>
<hr className="module-avatar-popup__divider" />
<button
type="button"
ref={focusRef}
className="module-avatar-popup__item"
onClick={onViewPreferences}
>

View File

@ -309,7 +309,6 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
/>
<div className="module-ForwardMessageModal__emoji">
<EmojiButton
doSend={noop}
i18n={i18n}
onClose={focusTextEditInput}
onPickEmoji={insertEmoji}

View File

@ -7,16 +7,28 @@ import { LocalizerType } from '../types/Util';
type PropsType = {
i18n: LocalizerType;
// ChatColorPicker
isChatColorEditorVisible: boolean;
renderChatColorPicker: () => JSX.Element;
toggleChatColorEditor: () => unknown;
// ProfileEditor
isProfileEditorVisible: boolean;
renderProfileEditor: () => JSX.Element;
};
export const GlobalModalContainer = ({
i18n,
// ChatColorPicker
isChatColorEditorVisible,
renderChatColorPicker,
toggleChatColorEditor,
// ProfileEditor
isProfileEditorVisible,
renderProfileEditor,
}: PropsType): JSX.Element | null => {
if (isChatColorEditorVisible) {
return (
@ -33,5 +45,9 @@ export const GlobalModalContainer = ({
);
}
if (isProfileEditorVisible) {
return renderProfileEditor();
}
return null;
};

View 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" />
</>
));

View File

@ -1,21 +1,10 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
ClipboardEvent,
forwardRef,
useEffect,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import React, { forwardRef } from 'react';
import { Input } from './Input';
import { LocalizerType } from '../types/Util';
import { multiRef } from '../util/multiRef';
import * as grapheme from '../util/grapheme';
const MAX_GRAPHEME_COUNT = 256;
const SHOW_REMAINING_COUNT = 150;
type PropsType = {
disabled?: boolean;
@ -24,128 +13,20 @@ type PropsType = {
value: string;
};
/**
* Group titles must have fewer than MAX_GRAPHEME_COUNT glyphs. Ideally, we'd use the
* `maxLength` property on inputs, but that doesn't account for glyphs that are more than
* one UTF-16 code units. For example: `'💩💩'.length === 4`.
*
* This component effectively implements a "max grapheme length" on an input.
*
* At a high level, this component handles two methods of input:
*
* - `onChange`. *Before* the value is changed (in `onKeyDown`), we save the value and the
* cursor position. Then, in `onChange`, we see if the new value is too long. If it is,
* we revert the value and selection. Otherwise, we fire `onChangeValue`.
*
* - `onPaste`. If you're pasting something that will fit, we fall back to normal browser
* behavior, which calls `onChange`. If you're pasting something that won't fit, it's a
* noop.
*/
export const GroupDescriptionInput = forwardRef<HTMLTextAreaElement, PropsType>(
export const GroupDescriptionInput = forwardRef<HTMLInputElement, PropsType>(
({ i18n, disabled = false, onChangeValue, value }, ref) => {
const innerRef = useRef<HTMLTextAreaElement | null>(null);
const valueOnKeydownRef = useRef<string>(value);
const selectionStartOnKeydownRef = useRef<number>(value.length);
const [isLarge, setIsLarge] = useState(false);
function maybeSetLarge() {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
if (inputEl.scrollHeight > inputEl.clientHeight) {
setIsLarge(true);
}
}
const onKeyDown = () => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
valueOnKeydownRef.current = inputEl.value;
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
};
const onChange = () => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
const newValue = inputEl.value;
const newGraphemeCount = grapheme.count(newValue);
if (newGraphemeCount <= MAX_GRAPHEME_COUNT) {
onChangeValue(newValue);
} else {
inputEl.value = valueOnKeydownRef.current;
inputEl.selectionStart = selectionStartOnKeydownRef.current;
inputEl.selectionEnd = selectionStartOnKeydownRef.current;
}
maybeSetLarge();
};
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
const selectionStart = inputEl.selectionStart || 0;
const selectionEnd = inputEl.selectionEnd || inputEl.selectionStart || 0;
const textBeforeSelection = value.slice(0, selectionStart);
const textAfterSelection = value.slice(selectionEnd);
const pastedText = event.clipboardData.getData('Text');
const newGraphemeCount =
grapheme.count(textBeforeSelection) +
grapheme.count(pastedText) +
grapheme.count(textAfterSelection);
if (newGraphemeCount > MAX_GRAPHEME_COUNT) {
event.preventDefault();
}
maybeSetLarge();
};
useEffect(() => {
maybeSetLarge();
}, []);
const graphemeCount = grapheme.count(value);
return (
<>
<div className="module-GroupInput--container module-GroupInput__description--container">
<textarea
className={classNames({
'module-GroupInput': true,
'module-GroupInput__description': true,
'module-GroupInput__description--large': isLarge,
})}
disabled={disabled}
onChange={onChange}
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>
</>
<Input
disabled={disabled}
expandable
i18n={i18n}
onChange={onChangeValue}
placeholder={i18n('setGroupMetadata__group-description-placeholder')}
maxGraphemeCount={256}
ref={ref}
value={value}
whenToShowRemainingCount={150}
/>
);
}
);

View File

@ -1,13 +1,10 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { forwardRef, useRef, ClipboardEvent } from 'react';
import React, { forwardRef } from 'react';
import { Input } from './Input';
import { LocalizerType } from '../types/Util';
import { multiRef } from '../util/multiRef';
import * as grapheme from '../util/grapheme';
const MAX_GRAPHEME_COUNT = 32;
type PropsType = {
disabled?: boolean;
@ -16,87 +13,18 @@ type PropsType = {
value: string;
};
/**
* Group titles must have fewer than MAX_GRAPHEME_COUNT glyphs. Ideally, we'd use the
* `maxLength` property on inputs, but that doesn't account for glyphs that are more than
* one UTF-16 code units. For example: `'💩💩'.length === 4`.
*
* This component effectively implements a "max grapheme length" on an input.
*
* At a high level, this component handles two methods of input:
*
* - `onChange`. *Before* the value is changed (in `onKeyDown`), we save the value and the
* cursor position. Then, in `onChange`, we see if the new value is too long. If it is,
* we revert the value and selection. Otherwise, we fire `onChangeValue`.
*
* - `onPaste`. If you're pasting something that will fit, we fall back to normal browser
* behavior, which calls `onChange`. If you're pasting something that won't fit, it's a
* noop.
*/
export const GroupTitleInput = forwardRef<HTMLInputElement, PropsType>(
({ i18n, disabled = false, onChangeValue, value }, ref) => {
const innerRef = useRef<HTMLInputElement | null>(null);
const valueOnKeydownRef = useRef<string>(value);
const selectionStartOnKeydownRef = useRef<number>(value.length);
return (
<div className="module-GroupInput--container">
<input
disabled={disabled}
className="module-GroupInput"
onKeyDown={() => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
valueOnKeydownRef.current = inputEl.value;
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
}}
onChange={() => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
const newValue = inputEl.value;
if (grapheme.count(newValue) <= MAX_GRAPHEME_COUNT) {
onChangeValue(newValue);
} else {
inputEl.value = valueOnKeydownRef.current;
inputEl.selectionStart = selectionStartOnKeydownRef.current;
inputEl.selectionEnd = selectionStartOnKeydownRef.current;
}
}}
onPaste={(event: ClipboardEvent<HTMLInputElement>) => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
const selectionStart = inputEl.selectionStart || 0;
const selectionEnd =
inputEl.selectionEnd || inputEl.selectionStart || 0;
const textBeforeSelection = value.slice(0, selectionStart);
const textAfterSelection = value.slice(selectionEnd);
const pastedText = event.clipboardData.getData('Text');
const newGraphemeCount =
grapheme.count(textBeforeSelection) +
grapheme.count(pastedText) +
grapheme.count(textAfterSelection);
if (newGraphemeCount > MAX_GRAPHEME_COUNT) {
event.preventDefault();
}
}}
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
ref={multiRef<HTMLInputElement>(ref, innerRef)}
type="text"
value={value}
/>
</div>
<Input
disabled={disabled}
i18n={i18n}
onChange={onChangeValue}
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
maxGraphemeCount={32}
ref={ref}
value={value}
/>
);
}
);

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

View File

@ -59,6 +59,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
showArchivedConversations: action('showArchivedConversations'),
startComposing: action('startComposing'),
toggleChatColorEditor: action('toggleChatColorEditor'),
toggleProfileEditor: action('toggleProfileEditor'),
});
story.add('Basic', () => {

View File

@ -65,6 +65,7 @@ export type PropsType = {
showArchivedConversations: () => void;
startComposing: () => void;
toggleChatColorEditor: () => void;
toggleProfileEditor: () => void;
};
type StateType = {
@ -353,6 +354,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
searchTerm,
showArchivedConversations,
toggleChatColorEditor,
toggleProfileEditor,
} = this.props;
const { showingAvatarPopup, popperRoot } = this.state;
@ -410,6 +412,10 @@ export class MainHeader extends React.Component<PropsType, StateType> {
size={28}
// See the comment above about `sharedGroupNames`.
sharedGroupNames={[]}
onEditProfile={() => {
toggleProfileEditor();
this.hideAvatarPopup();
}}
onSetChatColor={() => {
toggleChatColorEditor();
this.hideAvatarPopup();

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

View 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>
</>
);
};

View 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>
</>
);
};

View File

@ -4,21 +4,18 @@
import React, {
FormEventHandler,
FunctionComponent,
useEffect,
useRef,
useState,
} from 'react';
import { noop } from 'lodash';
import { LocalizerType } from '../../../types/Util';
import { Modal } from '../../Modal';
import { AvatarInput, AvatarInputVariant } from '../../AvatarInput';
import { AvatarInputContainer } from '../../AvatarInputContainer';
import { AvatarInputVariant } from '../../AvatarInput';
import { Button, ButtonVariant } from '../../Button';
import { Spinner } from '../../Spinner';
import { GroupDescriptionInput } from '../../GroupDescriptionInput';
import { GroupTitleInput } from '../../GroupTitleInput';
import * as log from '../../../logging/log';
import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer';
import { RequestState } from './util';
const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
@ -77,35 +74,6 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
}
};
useEffect(() => {
const startingAvatarPath = startingAvatarPathRef.current;
if (!startingAvatarPath) {
return noop;
}
let shouldCancel = false;
(async () => {
try {
const buffer = await imagePathToArrayBuffer(startingAvatarPath);
if (shouldCancel) {
return;
}
setAvatar(buffer);
} catch (err) {
log.warn(
`Failed to convert image URL to array buffer. Error message: ${
err && err.message
}`
);
}
})();
return () => {
shouldCancel = true;
};
}, []);
const hasChangedExternally =
startingAvatarPathRef.current !== externalAvatarPath ||
startingTitleRef.current !== externalTitle;
@ -154,15 +122,18 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
onSubmit={onSubmit}
className="module-EditConversationAttributesModal"
>
<AvatarInput
<AvatarInputContainer
avatarPath={externalAvatarPath}
contextMenuId="edit conversation attributes avatar input"
disabled={isRequestActive}
i18n={i18n}
onChange={newAvatar => {
onAvatarChanged={newAvatar => {
setAvatar(newAvatar);
setHasAvatarChanged(true);
}}
value={avatar}
onAvatarLoaded={loadedAvatar => {
setAvatar(loadedAvatar);
}}
variant={AvatarInputVariant.Dark}
/>
@ -217,25 +188,3 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
</Modal>
);
};
async function imagePathToArrayBuffer(src: string): Promise<ArrayBuffer> {
const image = new Image();
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error(
'imagePathToArrayBuffer: could not get canvas rendering context'
);
}
image.src = src;
await image.decode();
canvas.width = image.width;
canvas.height = image.height;
context.drawImage(image, 0, 0);
const result = await canvasToArrayBuffer(canvas);
return result;
}

View File

@ -6,10 +6,13 @@ import classNames from 'classnames';
import { get, noop } from 'lodash';
import { Manager, Popper, Reference } from 'react-popper';
import { createPortal } from 'react-dom';
import { Emoji } from './Emoji';
import { EmojiPicker, Props as EmojiPickerProps } from './EmojiPicker';
import { LocalizerType } from '../../types/Util';
export type OwnProps = {
readonly closeOnPick?: boolean;
readonly emoji?: string;
readonly i18n: LocalizerType;
readonly onClose?: () => unknown;
};
@ -22,6 +25,8 @@ export type Props = OwnProps &
export const EmojiButton = React.memo(
({
closeOnPick,
emoji,
i18n,
doSend,
onClose,
@ -114,9 +119,12 @@ export const EmojiButton = React.memo(
className={classNames({
'module-emoji-button__button': true,
'module-emoji-button__button--active': open,
'module-emoji-button__button--has-emoji': Boolean(emoji),
})}
aria-label={i18n('EmojiButton__label')}
/>
>
{emoji && <Emoji emoji={emoji} size={24} />}
</button>
)}
</Reference>
{open && popperRoot
@ -127,7 +135,12 @@ export const EmojiButton = React.memo(
ref={ref}
i18n={i18n}
style={style}
onPickEmoji={onPickEmoji}
onPickEmoji={ev => {
onPickEmoji(ev);
if (closeOnPick) {
handleClose();
}
}}
doSend={doSend}
onClose={handleClose}
skinTone={skinTone}

View File

@ -1366,6 +1366,8 @@ export class ConversationModel extends window.Backbone
e164: this.get('e164'),
about: this.getAboutText(),
aboutText: this.get('about'),
aboutEmoji: this.get('aboutEmoji'),
acceptedMessageRequest: this.getAccepted(),
activeAt: this.get('active_at')!,
areWePending: Boolean(
@ -1378,6 +1380,7 @@ export class ConversationModel extends window.Backbone
canChangeTimer: this.canChangeTimer(),
canEditGroupInfo: this.canEditGroupInfo(),
avatarPath: this.getAbsoluteAvatarPath(),
avatarHash: this.getAvatarHash(),
unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(),
color,
conversationColor: this.getConversationColor(),
@ -1387,6 +1390,7 @@ export class ConversationModel extends window.Backbone
draftBodyRanges,
draftPreview,
draftText,
familyName: this.get('profileFamilyName'),
firstName: this.get('profileName')!,
groupDescription: this.get('description'),
groupVersion,
@ -1417,6 +1421,7 @@ export class ConversationModel extends window.Backbone
messageCount: this.get('messageCount') || 0,
pendingMemberships: this.getPendingMemberships(),
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
profileKey: this.get('profileKey'),
messageRequestsEnabled,
accessControlAddFromInviteLink: this.get('accessControl')
?.addFromInviteLink,
@ -4522,6 +4527,10 @@ export class ConversationModel extends window.Backbone
c.unset('aboutEmoji');
}
if (profile.paymentAddress && isMe(c.attributes)) {
window.storage.put('paymentAddress', profile.paymentAddress);
}
if (profile.capabilities) {
c.set({ capabilities: profile.capabilities });
} else {
@ -4896,6 +4905,13 @@ export class ConversationModel extends window.Backbone
return avatar?.path || undefined;
}
private getAvatarHash(): undefined | string {
const avatar = isMe(this.attributes)
? this.get('profileAvatar') || this.get('avatar')
: this.get('avatar') || this.get('profileAvatar');
return avatar?.hash || undefined;
}
getAbsoluteAvatarPath(): string | undefined {
const avatarPath = this.getAvatarPath();
return avatarPath ? getAbsoluteAttachmentPath(avatarPath) : undefined;

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

View File

@ -17,11 +17,16 @@ import {
import { StateType as RootStateType } from '../reducer';
import * as groups from '../../groups';
import * as log from '../../logging/log';
import { calling } from '../../services/calling';
import { getOwn } from '../../util/getOwn';
import { assert } from '../../util/assert';
import * as universalExpireTimer from '../../util/universalExpireTimer';
import { trigger } from '../../shims/events';
import {
TOGGLE_PROFILE_EDITOR_ERROR,
ToggleProfileEditorErrorActionType,
} from './globalModals';
import {
AvatarColorType,
@ -45,6 +50,8 @@ import {
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import { writeProfile } from '../../services/writeProfile';
import { getMe } from '../selectors/conversations';
import { NoopActionType } from './noop';
@ -72,10 +79,14 @@ export type ConversationType = {
uuid?: string;
e164?: string;
name?: string;
familyName?: string;
firstName?: string;
profileName?: string;
about?: string;
aboutText?: string;
aboutEmoji?: string;
avatarPath?: string;
avatarHash?: string;
unblurredAvatarPath?: string;
areWeAdmin?: boolean;
areWePending?: boolean;
@ -157,7 +168,12 @@ export type ConversationType = {
secretParams?: string;
publicParams?: string;
acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle;
profileKey?: string;
};
export type ProfileDataType = {
firstName: string;
} & Pick<ConversationType, 'aboutEmoji' | 'aboutText' | 'familyName'>;
export type ConversationLookupType = {
[key: string]: ConversationType;
};
@ -673,6 +689,7 @@ export const actions = {
messagesAdded,
messageSizeChanged,
messagesReset,
myProfileChanged,
openConversationExternal,
openConversationInternal,
removeAllConversations,
@ -704,6 +721,41 @@ export const actions = {
toggleConversationInChooseMembers,
};
function myProfileChanged(
profileData: ProfileDataType,
avatarData?: ArrayBuffer
): ThunkAction<
void,
RootStateType,
unknown,
NoopActionType | ToggleProfileEditorErrorActionType
> {
return async (dispatch, getState) => {
const conversation = getMe(getState());
try {
await writeProfile(
{
...conversation,
...profileData,
},
avatarData
);
// writeProfile above updates the backbone model which in turn updates
// redux through it's on:change event listener. Once we lose Backbone
// we'll need to manually sync these new changes.
dispatch({
type: 'NOOP',
payload: null,
});
} catch (err) {
log.error('myProfileChanged', err && err.stack ? err.stack : err);
dispatch({ type: TOGGLE_PROFILE_EDITOR_ERROR });
}
};
}
function removeCustomColorOnConversations(
colorId: string
): ThunkAction<void, RootStateType, unknown, CustomColorRemovedActionType> {

View File

@ -5,33 +5,61 @@
export type GlobalModalsStateType = {
readonly isChatColorEditorVisible: boolean;
readonly isProfileEditorVisible: boolean;
readonly profileEditorHasError: boolean;
};
// Actions
const TOGGLE_CHAT_COLOR_EDITOR = 'globalModals/TOGGLE_CHAT_COLOR_EDITOR';
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
export const TOGGLE_PROFILE_EDITOR_ERROR =
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
type ToggleChatColorEditorActionType = {
type: typeof TOGGLE_CHAT_COLOR_EDITOR;
};
export type GlobalModalsActionType = ToggleChatColorEditorActionType;
type ToggleProfileEditorActionType = {
type: typeof TOGGLE_PROFILE_EDITOR;
};
export type ToggleProfileEditorErrorActionType = {
type: typeof TOGGLE_PROFILE_EDITOR_ERROR;
};
export type GlobalModalsActionType =
| ToggleChatColorEditorActionType
| ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType;
// Action Creators
export const actions = {
toggleChatColorEditor,
toggleProfileEditor,
toggleProfileEditorHasError,
};
function toggleChatColorEditor(): ToggleChatColorEditorActionType {
return { type: TOGGLE_CHAT_COLOR_EDITOR };
}
function toggleProfileEditor(): ToggleProfileEditorActionType {
return { type: TOGGLE_PROFILE_EDITOR };
}
function toggleProfileEditorHasError(): ToggleProfileEditorErrorActionType {
return { type: TOGGLE_PROFILE_EDITOR_ERROR };
}
// Reducer
export function getEmptyState(): GlobalModalsStateType {
return {
isChatColorEditorVisible: false,
isProfileEditorVisible: false,
profileEditorHasError: false,
};
}
@ -46,5 +74,19 @@ export function reducer(
};
}
if (action.type === TOGGLE_PROFILE_EDITOR) {
return {
...state,
isProfileEditorVisible: !state.isProfileEditorVisible,
};
}
if (action.type === TOGGLE_PROFILE_EDITOR_ERROR) {
return {
...state,
profileEditorHasError: !state.profileEditorHasError,
};
}
return state;
}

View File

@ -8,16 +8,28 @@ import { GlobalModalContainer } from '../../components/GlobalModalContainer';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { SmartChatColorPicker } from './ChatColorPicker';
import { SmartProfileEditorModal } from './ProfileEditorModal';
// Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
/* eslint-disable @typescript-eslint/no-explicit-any */
const FilteredSmartProfileEditorModal = SmartProfileEditorModal as any;
/* eslint-enable @typescript-eslint/no-explicit-any */
function renderChatColorPicker(): JSX.Element {
return <SmartChatColorPicker />;
}
function renderProfileEditor(): JSX.Element {
return <FilteredSmartProfileEditorModal />;
}
const mapStateToProps = (state: StateType) => {
return {
...state.globalModals,
i18n: getIntl(state),
renderChatColorPicker,
renderProfileEditor,
};
};

View 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);

View File

@ -311,8 +311,8 @@ const LAST_NAMES = [
'Jimenez',
];
const getFirstName = (): string => sample(FIRST_NAMES) || 'Test';
const getLastName = (): string => sample(LAST_NAMES) || 'Test';
export const getFirstName = (): string => sample(FIRST_NAMES) || 'Test';
export const getLastName = (): string => sample(LAST_NAMES) || 'Test';
export function getDefaultConversation(
overrideProps: Partial<ConversationType> = {}

View File

@ -5,7 +5,7 @@ import { assert } from 'chai';
import * as Curve from '../Curve';
import * as Crypto from '../Crypto';
import TSCrypto from '../textsecure/Crypto';
import TSCrypto, { PaddedLengths } from '../textsecure/Crypto';
describe('Crypto', () => {
describe('encrypting and decrypting profile data', () => {
@ -16,8 +16,12 @@ describe('Crypto', () => {
const buffer = Crypto.bytesFromString(name);
const key = Crypto.getRandomBytes(32);
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
buffer,
key,
PaddedLengths.Name
);
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
const { given, family } = await TSCrypto.decryptProfileName(
Crypto.arrayBufferToBase64(encrypted),
@ -32,8 +36,12 @@ describe('Crypto', () => {
const buffer = Crypto.bytesFromString(name);
const key = Crypto.getRandomBytes(32);
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
buffer,
key,
PaddedLengths.Name
);
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
const { given, family } = await TSCrypto.decryptProfileName(
Crypto.arrayBufferToBase64(encrypted),
key
@ -49,8 +57,12 @@ describe('Crypto', () => {
const buffer = Crypto.bytesFromString(name);
const key = Crypto.getRandomBytes(32);
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
buffer,
key,
PaddedLengths.Name
);
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
const { given, family } = await TSCrypto.decryptProfileName(
Crypto.arrayBufferToBase64(encrypted),
key
@ -70,8 +82,12 @@ describe('Crypto', () => {
const buffer = Crypto.bytesFromString(name);
const key = Crypto.getRandomBytes(32);
const encrypted = await TSCrypto.encryptProfileName(buffer, key);
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
buffer,
key,
PaddedLengths.Name
);
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
const { given, family } = await TSCrypto.decryptProfileName(
Crypto.arrayBufferToBase64(encrypted),
key
@ -84,8 +100,12 @@ describe('Crypto', () => {
const name = Crypto.bytesFromString('');
const key = Crypto.getRandomBytes(32);
const encrypted = await TSCrypto.encryptProfileName(name, key);
assert(encrypted.byteLength === NAME_PADDED_LENGTH + 16 + 12);
const encrypted = await TSCrypto.encryptProfileItemWithPadding(
name,
key,
PaddedLengths.Name
);
assert.equal(encrypted.byteLength, NAME_PADDED_LENGTH + 16 + 12);
const { given, family } = await TSCrypto.decryptProfileName(
Crypto.arrayBufferToBase64(encrypted),

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

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

View File

@ -117,7 +117,14 @@ declare global {
const PROFILE_IV_LENGTH = 12; // bytes
const PROFILE_KEY_LENGTH = 32; // bytes
const PROFILE_TAG_LENGTH = 128; // bits
const PROFILE_NAME_PADDED_LENGTH = 53; // bytes
// bytes
export const PaddedLengths = {
Name: [53, 257],
About: [128, 254, 512],
AboutEmoji: [32],
PaymentAddress: [554],
};
type EncryptedAttachment = {
ciphertext: ArrayBuffer;
@ -324,13 +331,20 @@ const Crypto = {
);
},
async encryptProfileName(
name: ArrayBuffer,
key: ArrayBuffer
async encryptProfileItemWithPadding(
item: ArrayBuffer,
profileKey: ArrayBuffer,
paddedLengths: typeof PaddedLengths[keyof typeof PaddedLengths]
): Promise<ArrayBuffer> {
const padded = new Uint8Array(PROFILE_NAME_PADDED_LENGTH);
padded.set(new Uint8Array(name));
return Crypto.encryptProfile(padded.buffer as ArrayBuffer, key);
const paddedLength = paddedLengths.find(
(length: number) => item.byteLength <= length
);
if (!paddedLength) {
throw new Error('Oversized value');
}
const padded = new Uint8Array(paddedLength);
padded.set(new Uint8Array(item));
return Crypto.encryptProfile(padded.buffer as ArrayBuffer, profileKey);
},
async decryptProfileName(

View File

@ -20,12 +20,14 @@ import { assert } from '../util/assert';
import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { SenderKeys } from '../LibSignalStores';
import {
ChallengeType,
GroupCredentialsType,
GroupLogResponseType,
ProxiedRequestOptionsType,
ChallengeType,
WebAPIType,
MultiRecipient200ResponseType,
ProfileRequestDataType,
ProxiedRequestOptionsType,
UploadAvatarHeadersType,
WebAPIType,
} from './WebAPI';
import createTaskWithTimeout from './TaskWithTimeout';
import OutgoingMessage, {
@ -2184,4 +2186,17 @@ export default class MessageSender {
): Promise<void> {
return this.server.sendChallengeResponse(challengeResponse);
}
async putProfile(
jsonData: ProfileRequestDataType
): Promise<UploadAvatarHeadersType | undefined> {
return this.server.putProfile(jsonData);
}
async uploadAvatar(
requestHeaders: UploadAvatarHeadersType,
avatarData: ArrayBuffer
): Promise<string> {
return this.server.uploadAvatar(requestHeaders, avatarData);
}
}

View File

@ -920,6 +920,29 @@ export type GroupLogResponseType = {
changes: Proto.GroupChanges;
};
export type ProfileRequestDataType = {
about: string | null;
aboutEmoji: string | null;
avatar: boolean;
commitment: string;
name: string;
paymentAddress: string | null;
version: string;
};
const uploadAvatarHeadersZod = z
.object({
acl: z.string(),
algorithm: z.string(),
credential: z.string(),
date: z.string(),
key: z.string(),
policy: z.string(),
signature: z.string(),
})
.passthrough();
export type UploadAvatarHeadersType = z.infer<typeof uploadAvatarHeadersZod>;
export type WebAPIType = {
confirmCode: (
number: string,
@ -1017,6 +1040,9 @@ export type WebAPIType = {
) => Promise<Proto.IGroupChange>;
modifyStorageRecords: MessageSender['modifyStorageRecords'];
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
putProfile: (
jsonData: ProfileRequestDataType
) => Promise<UploadAvatarHeadersType | undefined>;
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
putStickers: (
encryptedManifest: ArrayBuffer,
@ -1050,6 +1076,10 @@ export type WebAPIType = {
) => Promise<MultiRecipient200ResponseType>;
setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise<void>;
updateDeviceName: (deviceName: string) => Promise<void>;
uploadAvatar: (
uploadAvatarRequestHeaders: UploadAvatarHeadersType,
avatarData: ArrayBuffer
) => Promise<string>;
uploadGroupAvatar: (
avatarData: Uint8Array,
options: GroupCredentialsType
@ -1209,6 +1239,7 @@ export function initialize({
modifyGroup,
modifyStorageRecords,
putAttachment,
putProfile,
putStickers,
registerCapabilities,
registerKeys,
@ -1222,6 +1253,7 @@ export function initialize({
sendWithSenderKey,
setSignedPreKey,
updateDeviceName,
uploadAvatar,
uploadGroupAvatar,
whoami,
sendChallengeResponse,
@ -1424,6 +1456,23 @@ export function initialize({
});
}
async function putProfile(
jsonData: ProfileRequestDataType
): Promise<UploadAvatarHeadersType | undefined> {
const res = await _ajax({
call: 'profile',
httpType: 'PUT',
jsonData,
});
if (!res) {
return;
}
const parsed = JSON.parse(res);
return uploadAvatarHeadersZod.parse(parsed);
}
async function getProfileUnauth(
identifier: string,
options: {
@ -2195,6 +2244,27 @@ export function initialize({
};
}
async function uploadAvatar(
uploadAvatarRequestHeaders: UploadAvatarHeadersType,
avatarData: ArrayBuffer
): Promise<string> {
const verified = verifyAttributes(uploadAvatarRequestHeaders);
const { key } = verified;
const manifestParams = makePutParams(verified, avatarData);
await _outerAjax(`${cdnUrlObject['0']}/`, {
...manifestParams,
certificateAuthority,
proxyUrl,
timeout: 0,
type: 'POST',
version,
});
return key;
}
async function uploadGroupAvatar(
avatarData: Uint8Array,
options: GroupCredentialsType

View File

@ -109,6 +109,7 @@ export type StorageAccessType = {
'indexeddb-delete-needed': boolean;
senderCertificate: SerializedCertificateType;
senderCertificateNoE164: SerializedCertificateType;
paymentAddress: string;
// Deprecated
senderCertificateWithUuid: never;

View 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];
}

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

View File

@ -13257,6 +13257,13 @@
"updated": "2021-03-01T18:34:36.638Z",
"reasonDetail": "Used to reference popup menu"
},
{
"rule": "React-useRef",
"path": "ts/components/AvatarInputContainer.js",
"line": " const startingAvatarPathRef = react_1.useRef(avatarPath);",
"reasonCategory": "usageTrusted",
"updated": "2021-07-14T00:50:58.330Z"
},
{
"rule": "React-useRef",
"path": "ts/components/AvatarPopup.js",
@ -13544,51 +13551,6 @@
"updated": "2021-06-17T20:46:02.342Z",
"reasonDetail": "Doesn't reference the DOM."
},
{
"rule": "React-useRef",
"path": "ts/components/GroupDescriptionInput.js",
"line": " const innerRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-29T02:15:39.186Z"
},
{
"rule": "React-useRef",
"path": "ts/components/GroupDescriptionInput.js",
"line": " const valueOnKeydownRef = react_1.useRef(value);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-29T02:15:39.186Z"
},
{
"rule": "React-useRef",
"path": "ts/components/GroupDescriptionInput.js",
"line": " const selectionStartOnKeydownRef = react_1.useRef(value.length);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-29T02:15:39.186Z"
},
{
"rule": "React-useRef",
"path": "ts/components/GroupTitleInput.js",
"line": " const selectionStartOnKeydownRef = react_1.useRef(value.length);",
"reasonCategory": "usageTrusted",
"updated": "2021-03-05T16:51:54.214Z",
"reasonDetail": "Only stores a number."
},
{
"rule": "React-useRef",
"path": "ts/components/GroupTitleInput.js",
"line": " const valueOnKeydownRef = react_1.useRef(value);",
"reasonCategory": "usageTrusted",
"updated": "2021-03-05T16:51:54.214Z",
"reasonDetail": "Only stores a string."
},
{
"rule": "React-useRef",
"path": "ts/components/GroupTitleInput.js",
"line": " const innerRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-03-05T16:51:54.214Z",
"reasonDetail": "Used to handle an <input> element. Only updates the value and selection state."
},
{
"rule": "React-useRef",
"path": "ts/components/Inbox.js",
@ -13603,6 +13565,27 @@
"reasonCategory": "usageTrusted",
"updated": "2021-06-08T02:49:25.154Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Input.js",
"line": " const innerRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-07-14T00:50:58.330Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Input.js",
"line": " const valueOnKeydownRef = react_1.useRef(value);",
"reasonCategory": "usageTrusted",
"updated": "2021-07-14T00:50:58.330Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Input.js",
"line": " const selectionStartOnKeydownRef = react_1.useRef(value.length);",
"reasonCategory": "usageTrusted",
"updated": "2021-07-14T00:50:58.330Z"
},
{
"rule": "jQuery-$(",
"path": "ts/components/Intl.js",
@ -13656,6 +13639,13 @@
"updated": "2020-02-14T20:02:37.507Z",
"reasonDetail": "Used only to set focus"
},
{
"rule": "React-useRef",
"path": "ts/components/ProfileEditor.js",
"line": " const focusInputRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-07-14T00:50:58.330Z"
},
{
"rule": "React-createRef",
"path": "ts/components/SafetyNumberChangeDialog.js",

View File

@ -282,3 +282,13 @@ export function handleProfileKeyCredential(
return compatArrayToBase64(credentialArray);
}
export function deriveProfileKeyCommitment(
profileKeyBase64: string,
uuid: string
): string {
const profileKeyArray = base64ToCompatArray(profileKeyBase64);
const profileKey = new ProfileKey(profileKeyArray);
return compatArrayToBase64(profileKey.getCommitment(uuid).contents);
}