From cd35a2963845d9096bc48986e765ca2ca0fb3c95 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Mon, 19 Jul 2021 15:26:06 -0400 Subject: [PATCH] Edit profile --- _locales/en/messages.json | 74 +++ stylesheets/_modules.scss | 21 +- stylesheets/components/Input.scss | 125 +++++ stylesheets/components/ProfileEditor.scss | 84 ++++ stylesheets/manifest.scss | 4 +- ts/components/AvatarInput.tsx | 20 +- .../AvatarInputContainer.stories.tsx | 43 ++ ts/components/AvatarInputContainer.tsx | 86 ++++ ts/components/AvatarPopup.stories.tsx | 1 + ts/components/AvatarPopup.tsx | 12 +- ts/components/ForwardMessageModal.tsx | 1 - ts/components/GlobalModalContainer.tsx | 16 + .../GroupDescriptionInput.stories.tsx | 44 ++ ts/components/GroupDescriptionInput.tsx | 147 +----- ts/components/GroupTitleInput.tsx | 94 +--- ts/components/Input.stories.tsx | 93 ++++ ts/components/Input.tsx | 223 +++++++++ ts/components/MainHeader.stories.tsx | 1 + ts/components/MainHeader.tsx | 6 + ts/components/ProfileEditor.stories.tsx | 68 +++ ts/components/ProfileEditor.tsx | 431 ++++++++++++++++++ ts/components/ProfileEditorModal.tsx | 85 ++++ .../EditConversationAttributesModal.tsx | 67 +-- ts/components/emoji/EmojiButton.tsx | 17 +- ts/models/conversations.ts | 16 + ts/services/writeProfile.ts | 88 ++++ ts/state/ducks/conversations.ts | 52 +++ ts/state/ducks/globalModals.ts | 44 +- ts/state/smart/GlobalModalContainer.tsx | 12 + ts/state/smart/ProfileEditorModal.ts | 41 ++ .../helpers/getDefaultConversation.ts | 4 +- ts/test-electron/Crypto_test.ts | 42 +- .../util/encryptProfileData_test.ts | 85 ++++ .../util/imagePathToArrayBuffer_test.ts | 19 + ts/textsecure/Crypto.ts | 28 +- ts/textsecure/SendMessage.ts | 21 +- ts/textsecure/WebAPI.ts | 70 +++ ts/types/Storage.d.ts | 1 + ts/util/encryptProfileData.ts | 76 +++ ts/util/imagePathToArrayBuffer.ts | 28 ++ ts/util/lint/exceptions.json | 80 ++-- ts/util/zkgroup.ts | 10 + 42 files changed, 2124 insertions(+), 356 deletions(-) create mode 100644 stylesheets/components/Input.scss create mode 100644 stylesheets/components/ProfileEditor.scss create mode 100644 ts/components/AvatarInputContainer.stories.tsx create mode 100644 ts/components/AvatarInputContainer.tsx create mode 100644 ts/components/GroupDescriptionInput.stories.tsx create mode 100644 ts/components/Input.stories.tsx create mode 100644 ts/components/Input.tsx create mode 100644 ts/components/ProfileEditor.stories.tsx create mode 100644 ts/components/ProfileEditor.tsx create mode 100644 ts/components/ProfileEditorModal.tsx create mode 100644 ts/services/writeProfile.ts create mode 100644 ts/state/smart/ProfileEditorModal.ts create mode 100644 ts/test-electron/util/encryptProfileData_test.ts create mode 100644 ts/test-electron/util/imagePathToArrayBuffer_test.ts create mode 100644 ts/util/encryptProfileData.ts create mode 100644 ts/util/imagePathToArrayBuffer.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 4013c1c5d24d..3bdf1a28c08c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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." } } diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index c79d5200f146..e3e1f2d153a3 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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 { diff --git a/stylesheets/components/Input.scss b/stylesheets/components/Input.scss new file mode 100644 index 000000000000..6d8d485d0e2a --- /dev/null +++ b/stylesheets/components/Input.scss @@ -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; + } + } +} diff --git a/stylesheets/components/ProfileEditor.scss b/stylesheets/components/ProfileEditor.scss new file mode 100644 index 000000000000..fe397a8a928e --- /dev/null +++ b/stylesheets/components/ProfileEditor.scss @@ -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; + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 7a4d0758cdef..ba031475657d 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/components/AvatarInput.tsx b/ts/components/AvatarInput.tsx index 9944540ad609..5f1ad4419d09 100644 --- a/ts/components/AvatarInput.tsx +++ b/ts/components/AvatarInput.tsx @@ -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 = ({ disabled, i18n, onChange, + type, value, variant = AvatarInputVariant.Light, }) => { @@ -96,9 +103,14 @@ export const AvatarInput: FunctionComponent = ({ }; }, [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; diff --git a/ts/components/AvatarInputContainer.stories.tsx b/ts/components/AvatarInputContainer.stories.tsx new file mode 100644 index 000000000000..46f587f5dd48 --- /dev/null +++ b/ts/components/AvatarInputContainer.stories.tsx @@ -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)', () => ( + +)); + +story.add('No photo (profile)', () => ( + +)); + +story.add('Has photo', () => ( + +)); diff --git a/ts/components/AvatarInputContainer.tsx b/ts/components/AvatarInputContainer.tsx new file mode 100644 index 000000000000..bbda070806ae --- /dev/null +++ b/ts/components/AvatarInputContainer.tsx @@ -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(avatarPath); + + const [avatar, setAvatar] = useState( + 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 ( + { + setAvatar(newAvatar); + onAvatarChanged(newAvatar); + }} + type={type} + value={avatar} + variant={variant} + /> + ); +}; diff --git a/ts/components/AvatarPopup.stories.tsx b/ts/components/AvatarPopup.stories.tsx index d8473b7838a3..233ef207c155 100644 --- a/ts/components/AvatarPopup.stories.tsx +++ b/ts/components/AvatarPopup.stories.tsx @@ -40,6 +40,7 @@ const createProps = (overrideProps: Partial = {}): 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'), diff --git a/ts/components/AvatarPopup.tsx b/ts/components/AvatarPopup.tsx index e6d3abaee35d..3b91f5226136 100644 --- a/ts/components/AvatarPopup.tsx +++ b/ts/components/AvatarPopup.tsx @@ -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 (
-
+
+