diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 1f578985cc9c..cc73f4354296 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -949,6 +949,10 @@ "message": "Photo", "description": "Shown in a quotation of a message containing a photo if no text was originally provided with that image" }, + "text": { + "message": "Text", + "description": "Label for the word 'text'" + }, "cannotUpdate": { "message": "Cannot Update", "description": "Shown as the title of our update error dialogs on windows" @@ -1001,6 +1005,10 @@ "accept": { "message": "Accept" }, + "done": { + "message": "Done", + "description": "Label for done" + }, "on": { "message": "On", "description": "Label for when something is turned on" @@ -5792,7 +5800,7 @@ "message": "Last Name (Optional)", "description": "Placeholder text for last name field" }, - "ProfileEditor--discard": { + "ConfirmDiscardDialog--discard": { "message": "Would you like to discard these changes?", "description": "ConfirmationDialog text for discarding changes" }, @@ -5863,5 +5871,17 @@ "AnnouncementsOnlyGroupBanner--admins": { "message": "admins", "description": "Clickable text describing administrators of a group, used in the message an admin label" + }, + "AvatarEditor--choose": { + "message": "Select an avatar", + "description": "Label for the avatar selector" + }, + "AvatarColorPicker--choose": { + "message": "Choose a color", + "description": "Label for when you need to choose your fighter, err color" + }, + "LeftPaneSetGroupMetadataHelper__avatar-modal-title": { + "message": "Group Avatar", + "description": "Title for the avatar picker in the group creation flow" } } diff --git a/app/attachments.ts b/app/attachments.ts index 43451b411581..87ed87845312 100644 --- a/app/attachments.ts +++ b/app/attachments.ts @@ -34,6 +34,7 @@ try { } const PATH = 'attachments.noindex'; +const AVATAR_PATH = 'avatars.noindex'; const STICKER_PATH = 'stickers.noindex'; const TEMP_PATH = 'temp'; const DRAFT_PATH = 'drafts.noindex'; @@ -87,6 +88,13 @@ export const getPath = (userDataPath: string): string => { return join(userDataPath, PATH); }; +export const getAvatarsPath = (userDataPath: string): string => { + if (!isString(userDataPath)) { + throw new TypeError("'userDataPath' must be a string"); + } + return join(userDataPath, AVATAR_PATH); +}; + export const getStickersPath = (userDataPath: string): string => { if (!isString(userDataPath)) { throw new TypeError("'userDataPath' must be a string"); diff --git a/images/avatars/avatar_abstract_01.svg b/images/avatars/avatar_abstract_01.svg new file mode 100644 index 000000000000..69866d8bb2c7 --- /dev/null +++ b/images/avatars/avatar_abstract_01.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_abstract_02.svg b/images/avatars/avatar_abstract_02.svg new file mode 100644 index 000000000000..aed3f8d8398e --- /dev/null +++ b/images/avatars/avatar_abstract_02.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_abstract_03.svg b/images/avatars/avatar_abstract_03.svg new file mode 100644 index 000000000000..a4a06e4bbd20 --- /dev/null +++ b/images/avatars/avatar_abstract_03.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_balloon.svg b/images/avatars/avatar_balloon.svg new file mode 100644 index 000000000000..95384053c9e8 --- /dev/null +++ b/images/avatars/avatar_balloon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_book.svg b/images/avatars/avatar_book.svg new file mode 100644 index 000000000000..32e858076800 --- /dev/null +++ b/images/avatars/avatar_book.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_briefcase.svg b/images/avatars/avatar_briefcase.svg new file mode 100644 index 000000000000..8d167798e5ec --- /dev/null +++ b/images/avatars/avatar_briefcase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_cat.svg b/images/avatars/avatar_cat.svg new file mode 100644 index 000000000000..1db740e7b3c0 --- /dev/null +++ b/images/avatars/avatar_cat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_celebration.svg b/images/avatars/avatar_celebration.svg new file mode 100644 index 000000000000..30d9b699a6a2 --- /dev/null +++ b/images/avatars/avatar_celebration.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_dinosour.svg b/images/avatars/avatar_dinosour.svg new file mode 100644 index 000000000000..84ff60861d93 --- /dev/null +++ b/images/avatars/avatar_dinosour.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_dog.svg b/images/avatars/avatar_dog.svg new file mode 100644 index 000000000000..be6f81748b63 --- /dev/null +++ b/images/avatars/avatar_dog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_drink.svg b/images/avatars/avatar_drink.svg new file mode 100644 index 000000000000..f7c205dfa75a --- /dev/null +++ b/images/avatars/avatar_drink.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_football.svg b/images/avatars/avatar_football.svg new file mode 100644 index 000000000000..87ae30a88acf --- /dev/null +++ b/images/avatars/avatar_football.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_fox.svg b/images/avatars/avatar_fox.svg new file mode 100644 index 000000000000..26ca06bfcabd --- /dev/null +++ b/images/avatars/avatar_fox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_ghost.svg b/images/avatars/avatar_ghost.svg new file mode 100644 index 000000000000..73b0352fc0bb --- /dev/null +++ b/images/avatars/avatar_ghost.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_heart.svg b/images/avatars/avatar_heart.svg new file mode 100644 index 000000000000..a47b99d2d7af --- /dev/null +++ b/images/avatars/avatar_heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_house.svg b/images/avatars/avatar_house.svg new file mode 100644 index 000000000000..94d6b375bb36 --- /dev/null +++ b/images/avatars/avatar_house.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_incognito.svg b/images/avatars/avatar_incognito.svg new file mode 100644 index 000000000000..130cb395123b --- /dev/null +++ b/images/avatars/avatar_incognito.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_melon.svg b/images/avatars/avatar_melon.svg new file mode 100644 index 000000000000..e6793623a13f --- /dev/null +++ b/images/avatars/avatar_melon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_pig.svg b/images/avatars/avatar_pig.svg new file mode 100644 index 000000000000..7cea4c566579 --- /dev/null +++ b/images/avatars/avatar_pig.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_sloth.svg b/images/avatars/avatar_sloth.svg new file mode 100644 index 000000000000..45744525aa5f --- /dev/null +++ b/images/avatars/avatar_sloth.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_soccerball.svg b/images/avatars/avatar_soccerball.svg new file mode 100644 index 000000000000..b1d3ee13ad36 --- /dev/null +++ b/images/avatars/avatar_soccerball.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_sunset.svg b/images/avatars/avatar_sunset.svg new file mode 100644 index 000000000000..621f3f66647d --- /dev/null +++ b/images/avatars/avatar_sunset.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_surfboard.svg b/images/avatars/avatar_surfboard.svg new file mode 100644 index 000000000000..2bb2e5e5fe9a --- /dev/null +++ b/images/avatars/avatar_surfboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/avatars/avatar_tucan.svg b/images/avatars/avatar_tucan.svg new file mode 100644 index 000000000000..d060fd32649f --- /dev/null +++ b/images/avatars/avatar_tucan.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/photo-album-outline-24.svg b/images/icons/v2/photo-album-outline-24.svg new file mode 100644 index 000000000000..d93bcad96a9a --- /dev/null +++ b/images/icons/v2/photo-album-outline-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/text-24.svg b/images/icons/v2/text-24.svg new file mode 100644 index 000000000000..0e5e09922230 --- /dev/null +++ b/images/icons/v2/text-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/signal.js b/js/modules/signal.js index 5d45993cf570..1d5b29d443b0 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -188,6 +188,7 @@ function initializeMigrations({ createWriterForExisting, createWriterForNew, createDoesExist, + getAvatarsPath, getDraftPath, getPath, getStickersPath, @@ -238,11 +239,17 @@ function initializeMigrations({ const deleteDraftFile = Attachments.createDeleter(draftPath); const readDraftData = createReader(draftPath); + const avatarsPath = getAvatarsPath(userDataPath); + const getAbsoluteAvatarPath = createAbsolutePathGetter(avatarsPath); + const writeNewAvatarData = createWriterForNew(avatarsPath); + const deleteAvatar = Attachments.createDeleter(avatarsPath); + return { attachmentsPath, copyIntoAttachmentsDirectory, copyIntoTempDirectory, deleteAttachmentData: deleteOnDisk, + deleteAvatar, deleteDraftFile, deleteExternalMessageFiles: MessageType.deleteAllExternalFiles({ deleteAttachmentData: Type.deleteData(deleteOnDisk), @@ -252,6 +259,7 @@ function initializeMigrations({ deleteTempFile, doesAttachmentExist, getAbsoluteAttachmentPath, + getAbsoluteAvatarPath, getAbsoluteDraftPath, getAbsoluteStickerPath, getAbsoluteTempPath, @@ -312,6 +320,7 @@ function initializeMigrations({ logger, }), writeNewAttachmentData: createWriterForNew(attachmentsPath), + writeNewAvatarData, writeNewDraftData, }; } diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index fbec957656c4..61127aa0d3d3 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -540,3 +540,20 @@ } } } + +@mixin avatar-colors { + @each $color, $value in $avatar-colors { + &--#{$color} { + background-color: map-get($value, 'bg'); + color: map-get($value, 'fg'); + + &--icon { + background-color: map-get($value, 'fg'); + @include dark-theme { + // For specificity + background-color: map-get($value, 'fg'); + } + } + } + } +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 52ce42c9155f..99187e7d4f61 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -9797,11 +9797,7 @@ $contact-modal-padding: 18px; background-color: $color-black-alpha-40; } - @each $color, $value in $avatar-colors { - &__#{$color} { - background-color: $value; - } - } + @include avatar-colors(); } .module-tooltip { diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index eeea2ec60a98..ac1ce07ca738 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -117,36 +117,70 @@ $color-tangerine: ( // Avatars -$avatar-color-crimson: #d00b2c; -$avatar-color-vermilion: #c72a0a; -$avatar-color-burlap: #866118; -$avatar-color-forest: #067919; -$avatar-color-wintergreen: #067953; -$avatar-color-teal: #077288; -$avatar-color-blue: #0a69c7; -$avatar-color-indigo: #5151f6; -$avatar-color-violet: #a20ced; -$avatar-color-plum: #c70a88; -$avatar-color-taupe: #cb0b6b; -$avatar-color-steel: $color-gray-60; -$avatar-color-ultramarine: #0d59f2; +$avatar-color-A100: ( + bg: #e3e3fe, + fg: #3838f5, +); +$avatar-color-A110: ( + bg: #dde7fc, + fg: #1251d3, +); +$avatar-color-A120: ( + bg: #d8e8f0, + fg: #086da0, +); +$avatar-color-A130: ( + bg: #cde4cd, + fg: #067906, +); +$avatar-color-A140: ( + bg: #eae0fd, + fg: #661aff, +); +$avatar-color-A150: ( + bg: #f5e3fe, + fg: #9f00f0, +); +$avatar-color-A160: ( + bg: #f6d8ec, + fg: #b8057c, +); +$avatar-color-A170: ( + bg: #f5d7d7, + fg: #be0404, +); +$avatar-color-A180: ( + bg: #fef5d0, + fg: #836b01, +); +$avatar-color-A190: ( + bg: #eae6d5, + fg: #7d6f40, +); +$avatar-color-A200: ( + bg: #d2d2dc, + fg: #4f4f6d, +); +$avatar-color-A210: ( + bg: #d7d7d9, + fg: #5c5c5c, +); // Maps for easy manipulation $avatar-colors: ( - blue: $avatar-color-blue, - burlap: $avatar-color-burlap, - crimson: $avatar-color-crimson, - forest: $avatar-color-forest, - indigo: $avatar-color-indigo, - plum: $avatar-color-plum, - steel: $avatar-color-steel, - taupe: $avatar-color-taupe, - teal: $avatar-color-teal, - ultramarine: $avatar-color-ultramarine, - vermilion: $avatar-color-vermilion, - violet: $avatar-color-violet, - wintergreen: $avatar-color-wintergreen, + A100: $avatar-color-A100, + A110: $avatar-color-A110, + A120: $avatar-color-A120, + A130: $avatar-color-A130, + A140: $avatar-color-A140, + A150: $avatar-color-A150, + A160: $avatar-color-A160, + A170: $avatar-color-A170, + A180: $avatar-color-A180, + A190: $avatar-color-A190, + A200: $avatar-color-A200, + A210: $avatar-color-A210, ); $conversation-colors: ( diff --git a/stylesheets/components/Avatar.scss b/stylesheets/components/Avatar.scss index 509a63c49206..66547b0142f1 100644 --- a/stylesheets/components/Avatar.scss +++ b/stylesheets/components/Avatar.scss @@ -76,23 +76,12 @@ text-align: center; text-transform: uppercase; transition: font-size 100ms ease-out; - - @include light-theme { - color: $color-white; - } - @include dark-theme { - color: $color-gray-05; - } } &__icon { @mixin avatar-icon($icon) { - @include light-theme { - @include color-svg($icon, $color-white); - } - @include dark-theme { - @include color-svg($icon, $color-gray-05); - } + -webkit-mask: url($icon) no-repeat center; + -webkit-mask-size: 100%; } &--direct { @@ -118,24 +107,5 @@ padding: 4px; } - &--no-image { - background-color: $avatar-color-steel; - } - - &--signal-blue { - background-color: $avatar-color-ultramarine; - @include dark-theme { - background-color: $avatar-color-ultramarine; - } - } - - @each $color, $value in $avatar-colors { - &--#{$color} { - background-color: $value; - - @include dark-theme { - background-color: $value; - } - } - } + @include avatar-colors(); } diff --git a/stylesheets/components/AvatarEditor.scss b/stylesheets/components/AvatarEditor.scss new file mode 100644 index 000000000000..4b8c43bf2626 --- /dev/null +++ b/stylesheets/components/AvatarEditor.scss @@ -0,0 +1,95 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.AvatarEditor { + &__top-buttons { + display: flex; + } + + &__button { + @include button-reset; + align-items: center; + border-radius: 8px; + display: flex; + flex-direction: column; + font-size: 9px; + justify-content: center; + line-height: 14px; + margin: 0 8px; + min-height: 44px; + min-width: 60px; + padding: 0 8px; + + @include light-theme { + background-color: $color-gray-05; + color: $color-black; + } + + @include dark-theme { + background-color: $color-gray-65; + color: $color-gray-05; + } + + &::before { + content: ''; + display: block; + height: 18px; + width: 18px; + } + + @mixin button-icon($icon) { + @include light-theme { + @include color-svg($icon, $color-black); + } + @include dark-theme { + @include color-svg($icon, $color-gray-05); + } + } + + &--photo::before { + @include button-icon('../images/icons/v2/photo-album-outline-24.svg'); + } + + &--text::before { + @include button-icon('../images/icons/v2/text-24.svg'); + } + + &:focus { + box-shadow: 0 0 0 2px $color-ultramarine; + } + } + + &__avatars { + display: flex; + flex-wrap: wrap; + gap: 9px; + } + + &__divider { + border: none; + border-bottom: 1px solid $color-gray-15; + margin-bottom: 24px; + margin-top: 20px; + + @include light-theme { + border-color: $color-gray-15; + } + + @include dark-theme { + border-color: $color-gray-75; + } + } + + &__preview { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + } + + &__avatar-selector-title { + @include font-body-1-bold; + margin-bottom: 14px; + } +} diff --git a/stylesheets/components/AvatarInput.scss b/stylesheets/components/AvatarInput.scss deleted file mode 100644 index e011e705e7d0..000000000000 --- a/stylesheets/components/AvatarInput.scss +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -.module-AvatarInput { - @include button-reset; - - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - background: none; - - $dark-selector: '#{&}--dark'; - - &__avatar { - @include button-reset; - - margin-top: 4px; - display: flex; - border-radius: 100%; - height: 80px; - width: 80px; - transition: background-color 100ms ease-out; - - &--nothing { - align-items: stretch; - background: $color-white; - - @at-root '#{$dark-selector} #{&}' { - background: $color-ultramarine; - } - - &::before { - flex-grow: 1; - content: ''; - display: block; - @include color-svg( - '../images/icons/v2/camera-outline-24.svg', - $color-ultramarine, - false - ); - -webkit-mask-size: 24px 24px; - - @at-root '#{$dark-selector} #{&}' { - @include color-svg( - '../images/icons/v2/camera-outline-24.svg', - $color-white, - false - ); - } - } - } - - &--loading { - align-items: center; - background: $color-black; - } - - &--has-image { - background-size: cover; - background-position: center center; - } - } - - &__label { - @include button-reset; - @include font-body-1; - - padding-bottom: 4px; - padding-top: 4px; - - @include light-theme { - color: $color-ultramarine; - } - - @include dark-theme { - color: $color-ultramarine-light; - } - } - - @include keyboard-mode { - &:focus { - .module-AvatarInput__avatar { - box-shadow: inset 0 0 0 2px $color-ultramarine; - } - - .module-AvatarInput__label { - @include font-body-1-bold; - } - } - } -} diff --git a/stylesheets/components/AvatarModalButtons.scss b/stylesheets/components/AvatarModalButtons.scss new file mode 100644 index 000000000000..1d3d6248c874 --- /dev/null +++ b/stylesheets/components/AvatarModalButtons.scss @@ -0,0 +1,12 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.AvatarModalButtons { + bottom: 0; + position: absolute; + right: 0; + + .module-Button { + margin-left: 12px; + } +} diff --git a/stylesheets/components/AvatarPreview.scss b/stylesheets/components/AvatarPreview.scss new file mode 100644 index 000000000000..1b2dfe9f7791 --- /dev/null +++ b/stylesheets/components/AvatarPreview.scss @@ -0,0 +1,92 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.AvatarPreview { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + + &__avatar { + @include button-reset; + + align-items: center; + border-radius: 100%; + cursor: auto; + display: flex; + font-size: 32px; + height: 80px; + justify-content: center; + margin-bottom: 16px; + margin-top: 4px; + position: relative; + transition: background-color 100ms ease-out; + user-select: none; + width: 80px; + + &--loading { + background: $color-black; + } + + &--has-image { + background-size: cover; + background-position: center center; + } + } + + &__group { + -webkit-mask: url('../images/icons/v2/group-outline-40.svg') no-repeat + center; + -webkit-mask-size: 70%; + height: 100%; + width: 100%; + } + + &__upload { + align-items: center; + background: $color-gray-02; + border-radius: 100%; + bottom: 4px; + box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.12), 0px 2px 4px rgba(0, 0, 0, 0.2); + display: flex; + height: 28px; + justify-content: center; + position: absolute; + right: -7px; + width: 28px; + + &::after { + @include color-svg( + '../images/icons/v2/camera-outline-24.svg', + $color-black + ); + content: ''; + display: block; + height: 16px; + width: 16px; + } + } + + &__clear { + @include button-reset; + align-items: center; + background-color: $color-white; + border-radius: 100%; + box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.12), 0px 2px 4px rgba(0, 0, 0, 0.2); + display: flex; + height: 24px; + justify-content: center; + position: absolute; + right: 0; + top: 0; + width: 24px; + + &:after { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); + content: ''; + height: 14px; + width: 14px; + } + } +} diff --git a/stylesheets/components/AvatarTextEditor.scss b/stylesheets/components/AvatarTextEditor.scss new file mode 100644 index 000000000000..29e91a48862a --- /dev/null +++ b/stylesheets/components/AvatarTextEditor.scss @@ -0,0 +1,25 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.AvatarTextEditor { + &__input { + background: transparent; + border: none; + color: inherit; + outline: none; + padding: 0; + text-align: center; + text-transform: uppercase; + transition: font-size 30ms linear; + width: 100%; + } + + &__measure { + left: -9999; + position: fixed; + text-transform: uppercase; + top: -9999; + touch-action: none; + visibility: hidden; + } +} diff --git a/stylesheets/components/BetterAvatarBubble.scss b/stylesheets/components/BetterAvatarBubble.scss new file mode 100644 index 000000000000..0b6e1f39e4f6 --- /dev/null +++ b/stylesheets/components/BetterAvatarBubble.scss @@ -0,0 +1,88 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.BetterAvatarBubble { + align-items: center; + background-clip: content-box; + background-position: center; + border-color: transparent; + border-radius: 100%; + border-style: solid; + border-width: 2px; + cursor: pointer; + display: flex; + font-size: 32px; + height: 56px; + justify-content: center; + padding: 2px; + position: relative; + width: 56px; + + @include avatar-colors(); + + &--selected { + @include light-theme { + border-color: $color-black; + } + + @include dark-theme { + border-color: $color-white; + } + } + + @include keyboard-mode { + &:focus { + border-color: $color-ultramarine; + outline: none; + } + } + + &--editable { + display: flex; + align-items: center; + justify-content: center; + border-radius: 100%; + background: $color-black-alpha-20; + height: 100%; + width: 100%; + + &::after { + content: ''; + display: block; + height: 24px; + width: 24px; + @include color-svg( + '../images/icons/v2/compose-outline-24.svg', + $color-white + ); + } + } + + &__delete { + @include button-reset; + align-items: center; + background-color: $color-white; + border-radius: 100%; + box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.12), 0px 2px 4px rgba(0, 0, 0, 0.2); + display: none; + height: 20px; + justify-content: center; + position: absolute; + top: 0; + right: 0; + width: 20px; + + &:after { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); + content: ''; + height: 10px; + width: 10px; + } + } + + &:hover { + .BetterAvatarBubble__delete { + display: flex; + } + } +} diff --git a/stylesheets/components/Modal.scss b/stylesheets/components/Modal.scss index db3dddb9e150..d5ced0711ef3 100644 --- a/stylesheets/components/Modal.scss +++ b/stylesheets/components/Modal.scss @@ -7,7 +7,9 @@ margin: 0 auto; max-width: 360px; width: 95%; - max-height: 90vh; + // We need this to be a number not divisible by 5 so that if we have sticky + // buttons the bottom doesn't bleed through by 1px. + max-height: 89vh; display: flex; flex-direction: column; @@ -85,7 +87,8 @@ padding: 0 16px 16px 16px; border-top: 1px solid transparent; // If there's a header, just the body scrolls - overflow: auto; + overflow-y: scroll; // scroll so that the padding is always there + overflow-x: auto; &--scrolled { @include light-theme { @@ -102,7 +105,8 @@ &--no-header { padding: 16px; // If there's no header, the whole thing scrolls - overflow: auto; + overflow-y: scroll; // scroll so that the padding is always there + overflow-x: auto; } &__button-footer { @@ -120,6 +124,44 @@ flex-direction: column; align-items: flex-end; } + + .module-Modal--sticky-buttons & { + bottom: 0; + display: flex; + justify-content: flex-end; + padding: 16px 0; + position: sticky; + right: 0; + width: 100%; + z-index: 10; + + @include light-theme() { + background: $color-white; + } + + @include dark-theme() { + background: $color-gray-95; + } + } + } + + &--sticky-buttons { + .module-Modal__body { + padding-bottom: 0; + } + position: relative; + + .module-Modal__body--overflow { + .module-Modal__button-footer { + @include light-theme { + border-top: 1px solid $color-gray-05; + } + + @include dark-theme { + border-top: 1px solid $color-gray-80; + } + } + } } // Overrides for a modal with important message diff --git a/stylesheets/components/ProfileEditor.scss b/stylesheets/components/ProfileEditor.scss index fe397a8a928e..0f36f3ad5f56 100644 --- a/stylesheets/components/ProfileEditor.scss +++ b/stylesheets/components/ProfileEditor.scss @@ -2,19 +2,6 @@ // 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; @@ -79,6 +66,6 @@ &__info { @include font-body-2; color: $color-gray-60; - margin-top: 16px; + margin: 16px 0; } } diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 210b88d99e59..fc0d34c434e8 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -31,7 +31,11 @@ @import './components/App.scss'; @import './components/AnnouncementsOnlyGroupBanner.scss'; @import './components/Avatar.scss'; -@import './components/AvatarInput.scss'; +@import './components/AvatarEditor.scss'; +@import './components/AvatarModalButtons.scss'; +@import './components/AvatarPreview.scss'; +@import './components/AvatarTextEditor.scss'; +@import './components/BetterAvatarBubble.scss'; @import './components/Button.scss'; @import './components/CallingScreenSharingController.scss'; @import './components/CallingSelectPresentingSourcesModal.scss'; diff --git a/ts/components/Avatar.stories.tsx b/ts/components/Avatar.stories.tsx index ef0d52ed9701..2d9bd950373d 100644 --- a/ts/components/Avatar.stories.tsx +++ b/ts/components/Avatar.stories.tsx @@ -36,7 +36,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ : true, avatarPath: text('avatarPath', overrideProps.avatarPath || ''), blur: overrideProps.blur, - color: select('color', colorMap, overrideProps.color || 'blue'), + color: select('color', colorMap, overrideProps.color || AvatarColors[0]), conversationType: select( 'conversationType', conversationTypeMap, diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 64700e991a0a..8221d25fbf87 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -14,7 +14,7 @@ import { Spinner } from './Spinner'; import { getInitials } from '../util/getInitials'; import { LocalizerType } from '../types/Util'; -import { AvatarColorType } from '../types/Colors'; +import { AvatarColors, AvatarColorType } from '../types/Colors'; import * as log from '../logging/log'; import { assert } from '../util/assert'; import { shouldBlurAvatar } from '../util/shouldBlurAvatar'; @@ -70,7 +70,7 @@ export const Avatar: FunctionComponent = ({ acceptedMessageRequest, avatarPath, className, - color, + color = AvatarColors[0], conversationType, i18n, isMe, @@ -160,6 +160,7 @@ export const Avatar: FunctionComponent = ({
@@ -179,6 +180,7 @@ export const Avatar: FunctionComponent = ({
diff --git a/ts/components/AvatarColorPicker.stories.tsx b/ts/components/AvatarColorPicker.stories.tsx new file mode 100644 index 000000000000..ca9142a0e5a7 --- /dev/null +++ b/ts/components/AvatarColorPicker.stories.tsx @@ -0,0 +1,32 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +import { AvatarColorPicker, PropsType } from './AvatarColorPicker'; +import { AvatarColors } from '../types/Colors'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + i18n, + onColorSelected: action('onColorSelected'), + selectedColor: overrideProps.selectedColor, +}); + +const story = storiesOf('Components/AvatarColorPicker', module); + +story.add('Default', () => ); + +story.add('Selected', () => ( + +)); diff --git a/ts/components/AvatarColorPicker.tsx b/ts/components/AvatarColorPicker.tsx new file mode 100644 index 000000000000..ec4c1f62401c --- /dev/null +++ b/ts/components/AvatarColorPicker.tsx @@ -0,0 +1,40 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { AvatarColors, AvatarColorType } from '../types/Colors'; +import { LocalizerType } from '../types/Util'; +import { BetterAvatarBubble } from './BetterAvatarBubble'; + +export type PropsType = { + i18n: LocalizerType; + onColorSelected: (color: AvatarColorType) => unknown; + selectedColor?: AvatarColorType; +}; + +export const AvatarColorPicker = ({ + i18n, + onColorSelected, + selectedColor, +}: PropsType): JSX.Element => { + return ( + <> +
+ {i18n('AvatarColorPicker--choose')} +
+
+ {AvatarColors.map(color => ( + { + onColorSelected(color); + }} + /> + ))} +
+ + ); +}; diff --git a/ts/components/AvatarEditor.stories.tsx b/ts/components/AvatarEditor.stories.tsx new file mode 100644 index 000000000000..6c757f25fb85 --- /dev/null +++ b/ts/components/AvatarEditor.stories.tsx @@ -0,0 +1,98 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +import { AvatarColors } from '../types/Colors'; +import { AvatarEditor, PropsType } from './AvatarEditor'; +import { getDefaultAvatars } from '../types/Avatar'; +import { createAvatarData } from '../util/createAvatarData'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + avatarColor: overrideProps.avatarColor || AvatarColors[9], + avatarPath: overrideProps.avatarPath, + conversationId: '123', + conversationTitle: overrideProps.conversationTitle || 'Default Title', + deleteAvatarFromDisk: action('deleteAvatarFromDisk'), + i18n, + isGroup: Boolean(overrideProps.isGroup), + onCancel: action('onCancel'), + onSave: action('onSave'), + replaceAvatar: action('replaceAvatar'), + saveAvatarToDisk: action('saveAvatarToDisk'), + userAvatarData: overrideProps.userAvatarData || [ + createAvatarData({ + imagePath: '/fixtures/kitten-3-64-64.jpg', + }), + createAvatarData({ + color: 'A110', + text: 'YA', + }), + createAvatarData({ + color: 'A120', + text: 'OK', + }), + createAvatarData({ + color: 'A130', + text: 'F', + }), + createAvatarData({ + color: 'A140', + text: '🏄💣', + }), + createAvatarData({ + color: 'A150', + text: '😇🙃😆', + }), + createAvatarData({ + color: 'A160', + text: '🦊F💦', + }), + createAvatarData({ + color: 'A170', + text: 'J', + }), + createAvatarData({ + color: 'A180', + text: 'ZAP', + }), + createAvatarData({ + color: 'A190', + text: '🍍P', + }), + createAvatarData({ + color: 'A200', + text: '🌵', + }), + createAvatarData({ + color: 'A210', + text: 'NAP', + }), + ], +}); + +const story = storiesOf('Components/AvatarEditor', module); + +story.add('No Avatar (group)', () => ( + +)); +story.add('No Avatar (me)', () => ( + +)); + +story.add('Has Avatar', () => ( + +)); diff --git a/ts/components/AvatarEditor.tsx b/ts/components/AvatarEditor.tsx new file mode 100644 index 000000000000..62e1624c7cbc --- /dev/null +++ b/ts/components/AvatarEditor.tsx @@ -0,0 +1,298 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useEffect, useState } from 'react'; + +import { AvatarColorType } from '../types/Colors'; +import { + AvatarDataType, + DeleteAvatarFromDiskActionType, + ReplaceAvatarActionType, + SaveAvatarToDiskActionType, +} from '../types/Avatar'; +import { AvatarIconEditor } from './AvatarIconEditor'; +import { AvatarModalButtons } from './AvatarModalButtons'; +import { AvatarPreview } from './AvatarPreview'; +import { AvatarTextEditor } from './AvatarTextEditor'; +import { AvatarUploadButton } from './AvatarUploadButton'; +import { BetterAvatar } from './BetterAvatar'; +import { LocalizerType } from '../types/Util'; +import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer'; +import { createAvatarData } from '../util/createAvatarData'; +import { isSameAvatarData } from '../util/isSameAvatarData'; +import { missingCaseError } from '../util/missingCaseError'; + +export type PropsType = { + avatarColor?: AvatarColorType; + avatarPath?: string; + avatarValue?: ArrayBuffer; + conversationId?: string; + conversationTitle?: string; + deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; + i18n: LocalizerType; + isGroup?: boolean; + onCancel: () => unknown; + onSave: (buffer: ArrayBuffer | undefined) => unknown; + userAvatarData: ReadonlyArray; + replaceAvatar: ReplaceAvatarActionType; + saveAvatarToDisk: SaveAvatarToDiskActionType; +}; + +enum EditMode { + Main = 'Main', + Custom = 'Custom', + Text = 'Text', +} + +export const AvatarEditor = ({ + avatarColor, + avatarPath, + avatarValue, + conversationId, + conversationTitle, + deleteAvatarFromDisk, + i18n, + isGroup, + onCancel, + onSave, + userAvatarData, + replaceAvatar, + saveAvatarToDisk, +}: PropsType): JSX.Element => { + const [provisionalSelectedAvatar, setProvisionalSelectedAvatar] = useState< + AvatarDataType | undefined + >(); + const [avatarPreview, setAvatarPreview] = useState( + avatarValue + ); + const [initialAvatar, setInitialAvatar] = useState( + avatarValue + ); + const [localAvatarData, setLocalAvatarData] = useState>( + userAvatarData.slice() + ); + + const [editMode, setEditMode] = useState(EditMode.Main); + + const getSelectedAvatar = useCallback( + avatarToFind => + localAvatarData.find(avatarData => + isSameAvatarData(avatarData, avatarToFind) + ), + [localAvatarData] + ); + + const selectedAvatar = getSelectedAvatar(provisionalSelectedAvatar); + + // Caching the ArrayBuffer produced into avatarData as buffer because + // that function is a little expensive to run and so we don't flicker the UI. + useEffect(() => { + let shouldCancel = false; + + async function cacheAvatars() { + const newAvatarData = await Promise.all( + userAvatarData.map(async avatarData => { + if (avatarData.buffer) { + return avatarData; + } + const buffer = await avatarDataToArrayBuffer(avatarData); + return { + ...avatarData, + buffer, + }; + }) + ); + + if (!shouldCancel) { + setLocalAvatarData(newAvatarData); + } + } + + cacheAvatars(); + + return () => { + shouldCancel = true; + }; + }, [setLocalAvatarData, userAvatarData]); + + // This function optimistcally updates userAvatarData so we don't have to + // wait for saveAvatarToDisk to finish before displaying something to the + // user. As a bonus the component fully works in storybook! + const updateAvatarDataList = useCallback( + (newAvatarData?: AvatarDataType, staleAvatarData?: AvatarDataType) => { + const existingAvatarData = staleAvatarData + ? localAvatarData.filter(avatarData => avatarData !== staleAvatarData) + : localAvatarData; + + if (newAvatarData) { + setAvatarPreview(newAvatarData.buffer); + setLocalAvatarData([newAvatarData, ...existingAvatarData]); + setProvisionalSelectedAvatar(newAvatarData); + } else { + setLocalAvatarData(existingAvatarData); + if (isSameAvatarData(selectedAvatar, staleAvatarData)) { + setAvatarPreview(undefined); + setProvisionalSelectedAvatar(undefined); + } + } + }, + [ + localAvatarData, + selectedAvatar, + setAvatarPreview, + setLocalAvatarData, + setProvisionalSelectedAvatar, + ] + ); + + const handleAvatarLoaded = useCallback(avatarBuffer => { + setAvatarPreview(avatarBuffer); + setInitialAvatar(avatarBuffer); + }, []); + + const hasChanges = initialAvatar !== avatarPreview; + + let content: JSX.Element | undefined; + + if (editMode === EditMode.Main) { + content = ( + <> +
+ { + setAvatarPreview(undefined); + setProvisionalSelectedAvatar(undefined); + }} + /> +
+ { + const avatarData = createAvatarData({ + buffer: newAvatar, + // This is so that the newly created avatar gets an X + imagePath: 'TMP', + }); + saveAvatarToDisk(avatarData, conversationId); + updateAvatarDataList(avatarData); + }} + /> + +
+
+
+
+ {i18n('AvatarEditor--choose')} +
+
+ {localAvatarData.map(avatarData => ( + { + if (isSameAvatarData(avatarData, selectedAvatar)) { + if (avatarData.text) { + setEditMode(EditMode.Text); + } else if (avatarData.icon) { + setEditMode(EditMode.Custom); + } + } else { + setAvatarPreview(avatarBuffer); + setProvisionalSelectedAvatar(avatarData); + } + }} + onDelete={() => { + updateAvatarDataList(undefined, avatarData); + deleteAvatarFromDisk(avatarData, conversationId); + }} + /> + ))} +
+ { + if (selectedAvatar) { + replaceAvatar(selectedAvatar, selectedAvatar, conversationId); + } + onSave(avatarPreview); + }} + /> + + ); + } else if (editMode === EditMode.Text) { + content = ( + { + setEditMode(EditMode.Main); + if (selectedAvatar) { + return; + } + + // The selected avatar was cleared when we entered text mode so we + // need to find if one is actually selected if it matches the current + // preview. + const actualAvatarSelected = localAvatarData.find( + avatarData => avatarData.buffer === avatarPreview + ); + if (actualAvatarSelected) { + setProvisionalSelectedAvatar(actualAvatarSelected); + } + }} + onDone={(avatarBuffer, avatarData) => { + const newAvatarData = { + ...avatarData, + buffer: avatarBuffer, + }; + updateAvatarDataList(newAvatarData, selectedAvatar); + setEditMode(EditMode.Main); + replaceAvatar(newAvatarData, selectedAvatar, conversationId); + }} + /> + ); + } else if (editMode === EditMode.Custom) { + if (!selectedAvatar) { + throw new Error('No selected avatar and editMode is custom'); + } + + content = ( + { + if (avatarData) { + updateAvatarDataList(avatarData, selectedAvatar); + replaceAvatar(avatarData, selectedAvatar, conversationId); + } + setEditMode(EditMode.Main); + }} + /> + ); + } else { + throw missingCaseError(editMode); + } + + return
{content}
; +}; diff --git a/ts/components/AvatarIconEditor.stories.tsx b/ts/components/AvatarIconEditor.stories.tsx new file mode 100644 index 000000000000..3a6e52918fd9 --- /dev/null +++ b/ts/components/AvatarIconEditor.stories.tsx @@ -0,0 +1,46 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +import { AvatarIconEditor, PropsType } from './AvatarIconEditor'; +import { GroupAvatarIcons, PersonalAvatarIcons } from '../types/Avatar'; +import { AvatarColors } from '../types/Colors'; +import { createAvatarData } from '../util/createAvatarData'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + avatarData: overrideProps.avatarData || createAvatarData({}), + i18n, + onClose: action('onClose'), +}); + +const story = storiesOf('Components/AvatarIconEditor', module); + +story.add('Personal Icon', () => ( + +)); + +story.add('Group Icon', () => ( + +)); diff --git a/ts/components/AvatarIconEditor.tsx b/ts/components/AvatarIconEditor.tsx new file mode 100644 index 000000000000..52a434bd25b9 --- /dev/null +++ b/ts/components/AvatarIconEditor.tsx @@ -0,0 +1,81 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useEffect, useState } from 'react'; + +import { AvatarColorPicker } from './AvatarColorPicker'; +import { AvatarColorType } from '../types/Colors'; +import { AvatarDataType } from '../types/Avatar'; +import { AvatarModalButtons } from './AvatarModalButtons'; +import { AvatarPreview } from './AvatarPreview'; +import { LocalizerType } from '../types/Util'; +import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer'; + +export type PropsType = { + avatarData: AvatarDataType; + i18n: LocalizerType; + onClose: (avatarData?: AvatarDataType) => unknown; +}; + +export const AvatarIconEditor = ({ + avatarData: initialAvatarData, + i18n, + onClose, +}: PropsType): JSX.Element => { + const [avatarBuffer, setAvatarBuffer] = useState(); + const [avatarData, setAvatarData] = useState( + initialAvatarData + ); + + const onColorSelected = useCallback( + (color: AvatarColorType) => { + setAvatarData({ + ...avatarData, + color, + }); + }, + [avatarData] + ); + + useEffect(() => { + let shouldCancel = false; + + async function loadAvatar() { + const buffer = await avatarDataToArrayBuffer(avatarData); + if (!shouldCancel) { + setAvatarBuffer(buffer); + } + } + loadAvatar(); + + return () => { + shouldCancel = true; + }; + }, [avatarData, setAvatarBuffer]); + + const hasChanges = avatarData !== initialAvatarData; + + return ( + <> + +
+ + + onClose({ + ...avatarData, + buffer: avatarBuffer, + }) + } + /> + + ); +}; diff --git a/ts/components/AvatarInput.stories.tsx b/ts/components/AvatarInput.stories.tsx deleted file mode 100644 index 14949c22ca82..000000000000 --- a/ts/components/AvatarInput.stories.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React, { useState, useEffect } from 'react'; -import { v4 as uuid } from 'uuid'; -import { chunk, noop } from 'lodash'; - -import { storiesOf } from '@storybook/react'; -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; - -import { AvatarInput, AvatarInputVariant } from './AvatarInput'; - -const i18n = setupI18n('en', enMessages); - -const story = storiesOf('Components/AvatarInput', module); - -const TEST_IMAGE = new Uint8Array( - chunk( - '89504e470d0a1a0a0000000d4948445200000008000000080103000000fec12cc800000006504c5445ff00ff00ff000c82e9800000001849444154085b633061a8638863a867f8c720c760c12000001a4302f4d81dd9870000000049454e44ae426082', - 2 - ).map(bytePair => parseInt(bytePair.join(''), 16)) -).buffer; - -const Wrapper = ({ - startValue, - variant, -}: { - startValue: undefined | ArrayBuffer; - variant?: AvatarInputVariant; -}) => { - const [value, setValue] = useState(startValue); - const [objectUrl, setObjectUrl] = useState(); - - useEffect(() => { - if (!value) { - setObjectUrl(undefined); - return noop; - } - const url = URL.createObjectURL(new Blob([value])); - setObjectUrl(url); - return () => { - URL.revokeObjectURL(url); - }; - }, [value]); - - return ( - <> - -
-
Processed image (if it exists)
- {objectUrl && } -
- - ); -}; - -story.add('No start state', () => { - return ; -}); - -story.add('Starting with a value', () => { - return ; -}); - -story.add('Dark variant', () => { - return ; -}); diff --git a/ts/components/AvatarInput.tsx b/ts/components/AvatarInput.tsx deleted file mode 100644 index 5f1ad4419d09..000000000000 --- a/ts/components/AvatarInput.tsx +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React, { - useRef, - useState, - useEffect, - ChangeEventHandler, - MouseEventHandler, - FunctionComponent, -} from 'react'; -import classNames from 'classnames'; -import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu'; -import loadImage, { LoadImageOptions } from 'blueimp-load-image'; -import { noop } from 'lodash'; - -import { LocalizerType } from '../types/Util'; -import { Spinner } from './Spinner'; -import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer'; - -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; -}; - -enum ImageStatus { - Nothing = 'nothing', - Loading = 'loading', - HasImage = 'has-image', -} - -export enum AvatarInputType { - Profile = 'Profile', - Group = 'Group', -} - -export enum AvatarInputVariant { - Light = 'light', - Dark = 'dark', -} - -export const AvatarInput: FunctionComponent = ({ - contextMenuId, - disabled, - i18n, - onChange, - type, - value, - variant = AvatarInputVariant.Light, -}) => { - const fileInputRef = useRef(null); - // Comes from a third-party dependency - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const menuTriggerRef = useRef(null); - - const [objectUrl, setObjectUrl] = useState(); - useEffect(() => { - if (!value) { - setObjectUrl(undefined); - return noop; - } - const url = URL.createObjectURL(new Blob([value])); - setObjectUrl(url); - return () => { - URL.revokeObjectURL(url); - }; - }, [value]); - - const [processingFile, setProcessingFile] = useState( - undefined - ); - useEffect(() => { - if (!processingFile) { - return noop; - } - - let shouldCancel = false; - - (async () => { - let newValue: ArrayBuffer; - try { - newValue = await processFile(processingFile); - } catch (err) { - // Processing errors should be rare; if they do, we silently fail. In an ideal - // world, we may want to show a toast instead. - return; - } - if (shouldCancel) { - return; - } - setProcessingFile(undefined); - onChange(newValue); - })(); - - return () => { - shouldCancel = true; - }; - }, [processingFile, onChange]); - - 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; - if (fileInput) { - fileInput.click(); - } - }; - - const clear = () => { - onChange(undefined); - }; - - const onClick: MouseEventHandler = value - ? event => { - const menuTrigger = menuTriggerRef.current; - if (!menuTrigger) { - return; - } - menuTrigger.handleContextClick(event); - } - : startUpload; - - const onInputChange: ChangeEventHandler = event => { - const file = event.target.files && event.target.files[0]; - if (file) { - setProcessingFile(file); - } - }; - - let imageStatus: ImageStatus; - if (processingFile || (value && !objectUrl)) { - imageStatus = ImageStatus.Loading; - } else if (objectUrl) { - imageStatus = ImageStatus.HasImage; - } else { - imageStatus = ImageStatus.Nothing; - } - - const isLoading = imageStatus === ImageStatus.Loading; - - return ( - <> - - - - - - {i18n('AvatarInput--upload-photo-choice')} - - - {i18n('AvatarInput--remove-photo-choice')} - - - - - ); -}; - -async function processFile(file: File): Promise { - const { image } = await loadImage(file, { - canvas: true, - cover: true, - crop: true, - imageSmoothingQuality: 'medium', - maxHeight: 512, - maxWidth: 512, - minHeight: 2, - minWidth: 2, - // `imageSmoothingQuality` is not present in `loadImage`'s types, but it is - // documented and supported. Updating DefinitelyTyped is the long-term solution - // here. - } as LoadImageOptions); - - // NOTE: The types for `loadImage` say this can never be a canvas, but it will be if - // `canvas: true`, at least in our case. Again, updating DefinitelyTyped should - // address this. - if (!(image instanceof HTMLCanvasElement)) { - throw new Error('Loaded image was not a canvas'); - } - - return canvasToArrayBuffer(image); -} diff --git a/ts/components/AvatarInputContainer.stories.tsx b/ts/components/AvatarInputContainer.stories.tsx deleted file mode 100644 index 46f587f5dd48..000000000000 --- a/ts/components/AvatarInputContainer.stories.tsx +++ /dev/null @@ -1,43 +0,0 @@ -// 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 deleted file mode 100644 index bbda070806ae..000000000000 --- a/ts/components/AvatarInputContainer.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// 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/AvatarLightbox.stories.tsx b/ts/components/AvatarLightbox.stories.tsx new file mode 100644 index 000000000000..406fe0628c89 --- /dev/null +++ b/ts/components/AvatarLightbox.stories.tsx @@ -0,0 +1,59 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { select } from '@storybook/addon-knobs'; + +import enMessages from '../../_locales/en/messages.json'; +import { AvatarColors } from '../types/Colors'; +import { AvatarLightbox, PropsType } from './AvatarLightbox'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + avatarColor: select( + 'Color', + AvatarColors, + overrideProps.avatarColor || AvatarColors[0] + ), + avatarPath: overrideProps.avatarPath, + conversationTitle: overrideProps.conversationTitle, + i18n, + isGroup: Boolean(overrideProps.isGroup), + onClose: action('onClose'), +}); + +const story = storiesOf('Components/AvatarLightbox', module); + +story.add('Group', () => ( + +)); + +story.add('Person', () => { + const conversation = getDefaultConversation(); + return ( + + ); +}); + +story.add('Photo', () => ( + +)); diff --git a/ts/components/AvatarLightbox.tsx b/ts/components/AvatarLightbox.tsx new file mode 100644 index 000000000000..7e9ad9ee4694 --- /dev/null +++ b/ts/components/AvatarLightbox.tsx @@ -0,0 +1,64 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { AvatarColorType } from '../types/Colors'; +import { AvatarPreview } from './AvatarPreview'; +import { IMAGE_JPEG } from '../types/MIME'; +import { Lightbox } from './Lightbox'; +import { LocalizerType } from '../types/Util'; + +export type PropsType = { + avatarColor?: AvatarColorType; + avatarPath?: string; + conversationTitle?: string; + i18n: LocalizerType; + isGroup?: boolean; + onClose: () => unknown; +}; + +export const AvatarLightbox = ({ + avatarColor, + avatarPath, + conversationTitle, + i18n, + isGroup, + onClose, +}: PropsType): JSX.Element => { + if (avatarPath) { + return ( + + ); + } + + return ( + + + + ); +}; diff --git a/ts/components/AvatarModalButtons.stories.tsx b/ts/components/AvatarModalButtons.stories.tsx new file mode 100644 index 000000000000..5320148c24ae --- /dev/null +++ b/ts/components/AvatarModalButtons.stories.tsx @@ -0,0 +1,32 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import enMessages from '../../_locales/en/messages.json'; +import { AvatarModalButtons, PropsType } from './AvatarModalButtons'; +import { setup as setupI18n } from '../../js/modules/i18n'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + hasChanges: Boolean(overrideProps.hasChanges), + i18n, + onCancel: action('onCancel'), + onSave: action('onSave'), +}); + +const story = storiesOf('Components/AvatarModalButtons', module); + +story.add('Has changes', () => ( + +)); + +story.add('No changes', () => ); diff --git a/ts/components/AvatarModalButtons.tsx b/ts/components/AvatarModalButtons.tsx new file mode 100644 index 000000000000..cbca979aaa1b --- /dev/null +++ b/ts/components/AvatarModalButtons.tsx @@ -0,0 +1,54 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState } from 'react'; + +import { Button, ButtonVariant } from './Button'; +import { ConfirmDiscardDialog } from './ConfirmDiscardDialog'; +import { LocalizerType } from '../types/Util'; +import { Modal } from './Modal'; + +export type PropsType = { + hasChanges: boolean; + i18n: LocalizerType; + onCancel: () => unknown; + onSave: () => unknown; +}; + +export const AvatarModalButtons = ({ + hasChanges, + i18n, + onCancel, + onSave, +}: PropsType): JSX.Element => { + const [confirmDiscardAction, setConfirmDiscardAction] = useState< + (() => unknown) | undefined + >(undefined); + + return ( + + + + {confirmDiscardAction && ( + setConfirmDiscardAction(undefined)} + /> + )} + + ); +}; diff --git a/ts/components/AvatarPopup.stories.tsx b/ts/components/AvatarPopup.stories.tsx index c6c34d25d9ae..341940292c5f 100644 --- a/ts/components/AvatarPopup.stories.tsx +++ b/ts/components/AvatarPopup.stories.tsx @@ -30,7 +30,7 @@ const conversationTypeMap: Record = { const createProps = (overrideProps: Partial = {}): Props => ({ acceptedMessageRequest: true, avatarPath: text('avatarPath', overrideProps.avatarPath || ''), - color: select('color', colorMap, overrideProps.color || 'blue'), + color: select('color', colorMap, overrideProps.color || AvatarColors[0]), conversationType: select( 'conversationType', conversationTypeMap, diff --git a/ts/components/AvatarPreview.stories.tsx b/ts/components/AvatarPreview.stories.tsx new file mode 100644 index 000000000000..afea1fa7149c --- /dev/null +++ b/ts/components/AvatarPreview.stories.tsx @@ -0,0 +1,94 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { chunk } from 'lodash'; + +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; + +import { AvatarPreview, PropsType } from './AvatarPreview'; +import { AvatarColors } from '../types/Colors'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const TEST_IMAGE = new Uint8Array( + chunk( + '89504e470d0a1a0a0000000d4948445200000008000000080103000000fec12cc800000006504c5445ff00ff00ff000c82e9800000001849444154085b633061a8638863a867f8c720c760c12000001a4302f4d81dd9870000000049454e44ae426082', + 2 + ).map(bytePair => parseInt(bytePair.join(''), 16)) +).buffer; + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + avatarColor: overrideProps.avatarColor, + avatarPath: overrideProps.avatarPath, + avatarValue: overrideProps.avatarValue, + conversationTitle: overrideProps.conversationTitle, + i18n, + isEditable: Boolean(overrideProps.isEditable), + isGroup: Boolean(overrideProps.isGroup), + onAvatarLoaded: action('onAvatarLoaded'), + onClear: action('onClear'), + onClick: action('onClick'), + style: overrideProps.style, +}); + +const story = storiesOf('Components/AvatarPreview', module); + +story.add('No state (personal)', () => ( + +)); + +story.add('No state (group)', () => ( + +)); + +story.add('No state (group) + upload me', () => ( + +)); + +story.add('value', () => ( + +)); + +story.add('path', () => ( + +)); + +story.add('value & path', () => ( + +)); + +story.add('style', () => ( + +)); diff --git a/ts/components/AvatarPreview.tsx b/ts/components/AvatarPreview.tsx new file mode 100644 index 000000000000..45ff70fd7bec --- /dev/null +++ b/ts/components/AvatarPreview.tsx @@ -0,0 +1,184 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { CSSProperties, useEffect, useRef, useState } from 'react'; +import { noop } from 'lodash'; + +import * as log from '../logging/log'; +import { LocalizerType } from '../types/Util'; +import { Spinner } from './Spinner'; +import { AvatarColors, AvatarColorType } from '../types/Colors'; +import { getInitials } from '../util/getInitials'; +import { imagePathToArrayBuffer } from '../util/imagePathToArrayBuffer'; + +export type PropsType = { + avatarColor?: AvatarColorType; + avatarPath?: string; + avatarValue?: ArrayBuffer; + conversationTitle?: string; + i18n: LocalizerType; + isEditable?: boolean; + isGroup?: boolean; + onAvatarLoaded?: (avatarBuffer: ArrayBuffer) => unknown; + onClear?: () => unknown; + onClick?: () => unknown; + style?: CSSProperties; +}; + +enum ImageStatus { + Nothing = 'nothing', + Loading = 'loading', + HasImage = 'has-image', +} + +export const AvatarPreview = ({ + avatarColor = AvatarColors[0], + avatarPath, + avatarValue, + conversationTitle, + i18n, + isEditable, + isGroup, + onAvatarLoaded, + onClear, + onClick, + style = {}, +}: PropsType): JSX.Element => { + const startingAvatarPathRef = useRef( + avatarValue ? undefined : avatarPath + ); + + const [avatarPreview, setAvatarPreview] = useState(); + + // Loads the initial avatarPath if one is provided. + useEffect(() => { + const startingAvatarPath = startingAvatarPathRef.current; + if (!startingAvatarPath) { + return noop; + } + + let shouldCancel = false; + + (async () => { + try { + const buffer = await imagePathToArrayBuffer(startingAvatarPath); + if (shouldCancel) { + return; + } + setAvatarPreview(buffer); + if (onAvatarLoaded) { + onAvatarLoaded(buffer); + } + } catch (err) { + if (shouldCancel) { + return; + } + log.warn( + `Failed to convert image URL to array buffer. Error message: ${ + err && err.message + }` + ); + } + })(); + + return () => { + shouldCancel = true; + }; + }, [onAvatarLoaded]); + + // Ensures that when avatarValue changes we generate new URLs + useEffect(() => { + if (avatarValue) { + setAvatarPreview(avatarValue); + } else { + setAvatarPreview(undefined); + } + }, [avatarValue]); + + // Creates the object URL to render the ArrayBuffer image + const [objectUrl, setObjectUrl] = useState(); + + useEffect(() => { + if (!avatarPreview) { + setObjectUrl(undefined); + return noop; + } + + const url = URL.createObjectURL(new Blob([avatarPreview])); + setObjectUrl(url); + + return () => { + URL.revokeObjectURL(url); + }; + }, [avatarPreview]); + + let imageStatus: ImageStatus; + if (avatarValue && !objectUrl) { + imageStatus = ImageStatus.Loading; + } else if (objectUrl) { + imageStatus = ImageStatus.HasImage; + } else { + imageStatus = ImageStatus.Nothing; + } + + const isLoading = imageStatus === ImageStatus.Loading; + + const clickProps = onClick ? { role: 'button', onClick } : {}; + const componentStyle = { + ...style, + }; + if (onClick) { + componentStyle.cursor = 'pointer'; + } + + if (!avatarPreview) { + return ( +
+
+ {isGroup ? ( +
+ ) : ( + getInitials(conversationTitle) + )} + {isEditable &&
} +
+
+ ); + } + + return ( +
+
+ {isLoading && ( + + )} + {imageStatus === ImageStatus.HasImage && onClear && ( +
+
+ ); +}; diff --git a/ts/components/AvatarTextEditor.stories.tsx b/ts/components/AvatarTextEditor.stories.tsx new file mode 100644 index 000000000000..2519a0dede43 --- /dev/null +++ b/ts/components/AvatarTextEditor.stories.tsx @@ -0,0 +1,49 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +import { AvatarTextEditor, PropsType } from './AvatarTextEditor'; +import { AvatarColors } from '../types/Colors'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + avatarData: overrideProps.avatarData, + i18n, + onCancel: action('onCancel'), + onDone: action('onDone'), +}); + +const story = storiesOf('Components/AvatarTextEditor', module); + +story.add('Empty', () => ); + +story.add('with Data', () => ( + +)); + +story.add('with wide characters', () => ( + +)); diff --git a/ts/components/AvatarTextEditor.tsx b/ts/components/AvatarTextEditor.tsx new file mode 100644 index 000000000000..9a8218702fef --- /dev/null +++ b/ts/components/AvatarTextEditor.tsx @@ -0,0 +1,197 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { + ChangeEvent, + ClipboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { noop } from 'lodash'; + +import * as grapheme from '../util/grapheme'; +import { AvatarColorPicker } from './AvatarColorPicker'; +import { AvatarColors } from '../types/Colors'; +import { AvatarDataType } from '../types/Avatar'; +import { AvatarModalButtons } from './AvatarModalButtons'; +import { BetterAvatarBubble } from './BetterAvatarBubble'; +import { LocalizerType } from '../types/Util'; +import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer'; +import { createAvatarData } from '../util/createAvatarData'; +import { + getFittedFontSize, + getFontSizes, +} from '../util/avatarTextSizeCalculator'; + +type DoneHandleType = ( + avatarBuffer: ArrayBuffer, + avatarData: AvatarDataType +) => unknown; + +export type PropsType = { + avatarData?: AvatarDataType; + i18n: LocalizerType; + onCancel: () => unknown; + onDone: DoneHandleType; +}; + +const BUBBLE_SIZE = 120; +const MAX_LENGTH = 3; + +export const AvatarTextEditor = ({ + avatarData, + i18n, + onCancel, + onDone, +}: PropsType): JSX.Element => { + const initialText = useMemo(() => avatarData?.text || '', [avatarData]); + const initialColor = useMemo(() => avatarData?.color || AvatarColors[0], [ + avatarData, + ]); + + const [inputText, setInputText] = useState(initialText); + const [fontSize, setFontSize] = useState(getFontSizes(BUBBLE_SIZE).text); + const [selectedColor, setSelectedColor] = useState(initialColor); + + const inputRef = useRef(null); + + const focusInput = useCallback(() => { + const inputEl = inputRef?.current; + if (inputEl) { + inputEl.focus(); + } + }, []); + + const handleChange = useCallback( + (ev: ChangeEvent) => { + const { value } = ev.target; + if (grapheme.count(value) <= MAX_LENGTH) { + setInputText(ev.target.value); + } + }, + [setInputText] + ); + + const handlePaste = useCallback( + (ev: ClipboardEvent) => { + const inputEl = ev.currentTarget; + + const selectionStart = inputEl.selectionStart || 0; + const selectionEnd = inputEl.selectionEnd || inputEl.selectionStart || 0; + const textBeforeSelection = inputText.slice(0, selectionStart); + const textAfterSelection = inputText.slice(selectionEnd); + + const pastedText = ev.clipboardData.getData('Text'); + + const newGraphemeCount = + grapheme.count(textBeforeSelection) + + grapheme.count(pastedText) + + grapheme.count(textAfterSelection); + + if (newGraphemeCount > MAX_LENGTH) { + ev.preventDefault(); + } + }, + [inputText] + ); + + const onDoneRef = useRef(onDone); + + // Make sure we keep onDoneRef up to date + useEffect(() => { + onDoneRef.current = onDone; + }, [onDone]); + + const handleDone = useCallback(async () => { + const newAvatarData = createAvatarData({ + color: selectedColor, + text: inputText, + }); + + const buffer = await avatarDataToArrayBuffer(newAvatarData); + + onDoneRef.current(buffer, newAvatarData); + }, [inputText, selectedColor]); + + // In case the component unmounts before we're able to create the avatar data + // we set the done handler to a no-op. + useEffect(() => { + return () => { + onDoneRef.current = noop; + }; + }, []); + + const measureElRef = useRef(null); + useEffect(() => { + const measureEl = measureElRef.current; + if (!measureEl) { + return; + } + + const nextFontSize = getFittedFontSize( + BUBBLE_SIZE, + inputText, + candidateFontSize => { + measureEl.style.fontSize = `${candidateFontSize}px`; + const { width, height } = measureEl.getBoundingClientRect(); + return { height, width }; + } + ); + + setFontSize(nextFontSize); + }, [inputText]); + + useEffect(() => { + focusInput(); + }, [focusInput]); + + const hasChanges = + initialText !== inputText || selectedColor !== initialColor; + + return ( + <> +
+ + + +
+
+ { + setSelectedColor(color); + focusInput(); + }} + selectedColor={selectedColor} + /> + +
+ {inputText} +
+ + ); +}; diff --git a/ts/components/AvatarUploadButton.stories.tsx b/ts/components/AvatarUploadButton.stories.tsx new file mode 100644 index 000000000000..9a10cf938de6 --- /dev/null +++ b/ts/components/AvatarUploadButton.stories.tsx @@ -0,0 +1,23 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +import { AvatarUploadButton, PropsType } from './AvatarUploadButton'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + className: overrideProps.className || '', + i18n, + onChange: action('onChange'), +}); + +const story = storiesOf('Components/AvatarUploadButton', module); + +story.add('Default', () => ); diff --git a/ts/components/AvatarUploadButton.tsx b/ts/components/AvatarUploadButton.tsx new file mode 100644 index 000000000000..d201540080af --- /dev/null +++ b/ts/components/AvatarUploadButton.tsx @@ -0,0 +1,86 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ChangeEventHandler, useEffect, useRef, useState } from 'react'; +import { noop } from 'lodash'; + +import { LocalizerType } from '../types/Util'; +import { processImageFile } from '../util/processImageFile'; + +export type PropsType = { + className: string; + i18n: LocalizerType; + onChange: (avatar: ArrayBuffer) => unknown; +}; + +export const AvatarUploadButton = ({ + className, + i18n, + onChange, +}: PropsType): JSX.Element => { + const fileInputRef = useRef(null); + + const [processingFile, setProcessingFile] = useState(); + + useEffect(() => { + if (!processingFile) { + return noop; + } + + let shouldCancel = false; + + (async () => { + let newAvatar: ArrayBuffer; + try { + newAvatar = await processImageFile(processingFile); + } catch (err) { + // Processing errors should be rare; if they do, we silently fail. In an ideal + // world, we may want to show a toast instead. + return; + } + if (shouldCancel) { + return; + } + setProcessingFile(undefined); + onChange(newAvatar); + })(); + + return () => { + shouldCancel = true; + }; + }, [onChange, processingFile]); + + const onInputChange: ChangeEventHandler = event => { + const file = event.target.files && event.target.files[0]; + if (file) { + setProcessingFile(file); + } + }; + + return ( + <> + + + + ); +}; diff --git a/ts/components/BetterAvatar.stories.tsx b/ts/components/BetterAvatar.stories.tsx new file mode 100644 index 000000000000..1ee3eeaf0686 --- /dev/null +++ b/ts/components/BetterAvatar.stories.tsx @@ -0,0 +1,62 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import enMessages from '../../_locales/en/messages.json'; +import { AvatarColors } from '../types/Colors'; +import { GroupAvatarIcons, PersonalAvatarIcons } from '../types/Avatar'; +import { BetterAvatar, PropsType } from './BetterAvatar'; +import { createAvatarData } from '../util/createAvatarData'; +import { setup as setupI18n } from '../../js/modules/i18n'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + avatarData: + overrideProps.avatarData || + createAvatarData({ color: AvatarColors[0], text: 'OOO' }), + i18n, + isSelected: Boolean(overrideProps.isSelected), + onClick: action('onClick'), + onDelete: action('onDelete'), + size: 80, +}); + +const story = storiesOf('Components/BetterAvatar', module); + +story.add('Text', () => ( + +)); + +story.add('Personal Icon', () => ( + +)); + +story.add('Group Icon', () => ( + +)); diff --git a/ts/components/BetterAvatar.tsx b/ts/components/BetterAvatar.tsx new file mode 100644 index 000000000000..8ffd5a94559e --- /dev/null +++ b/ts/components/BetterAvatar.tsx @@ -0,0 +1,117 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { MouseEvent, useEffect, useState } from 'react'; +import { noop } from 'lodash'; +import { AvatarDataType } from '../types/Avatar'; +import { BetterAvatarBubble } from './BetterAvatarBubble'; +import { LocalizerType } from '../types/Util'; +import { Spinner } from './Spinner'; +import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer'; + +type AvatarSize = 48 | 80; + +export type PropsType = { + avatarData: AvatarDataType; + i18n: LocalizerType; + isSelected?: boolean; + onClick: (avatarBuffer: ArrayBuffer | undefined) => unknown; + onDelete: () => unknown; + size?: AvatarSize; +}; + +export const BetterAvatar = ({ + avatarData, + i18n, + isSelected, + onClick, + onDelete, + size = 48, +}: PropsType): JSX.Element => { + const [avatarBuffer, setAvatarBuffer] = useState( + avatarData.buffer + ); + const [avatarURL, setAvatarURL] = useState(undefined); + + useEffect(() => { + let shouldCancel = false; + + async function makeAvatar() { + const buffer = await avatarDataToArrayBuffer(avatarData); + if (!shouldCancel) { + setAvatarBuffer(buffer); + } + } + + // If we don't have this we'll get lots of flashing because avatarData + // changes too much. Once we have a buffer set we don't need to reload. + if (avatarBuffer) { + return noop; + } + + makeAvatar(); + + return () => { + shouldCancel = true; + }; + }, [avatarBuffer, avatarData]); + + // Convert avatar's ArrayBuffer to a URL object + useEffect(() => { + if (avatarBuffer) { + const url = URL.createObjectURL(new Blob([avatarBuffer])); + + setAvatarURL(url); + } + }, [avatarBuffer]); + + // Clean up any remaining object URLs + useEffect(() => { + return () => { + if (avatarURL) { + URL.revokeObjectURL(avatarURL); + } + }; + }, [avatarURL]); + + const isEditable = Boolean(avatarData.color); + const handleDelete = !avatarData.icon + ? (ev: MouseEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + onDelete(); + } + : undefined; + + return ( + { + onClick(avatarBuffer); + }} + style={{ + backgroundImage: avatarURL ? `url(${avatarURL})` : undefined, + backgroundSize: size, + // +8 so that the size is the acutal size we want, 8 is the invisible + // padding around the bubble to make room for the selection border + height: size + 8, + width: size + 8, + }} + > + {isEditable && isSelected && ( +
+ )} + {!avatarURL && ( +
+ +
+ )} + + ); +}; diff --git a/ts/components/BetterAvatarBubble.stories.tsx b/ts/components/BetterAvatarBubble.stories.tsx new file mode 100644 index 000000000000..f934721240c8 --- /dev/null +++ b/ts/components/BetterAvatarBubble.stories.tsx @@ -0,0 +1,56 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import enMessages from '../../_locales/en/messages.json'; +import { AvatarColors } from '../types/Colors'; +import { BetterAvatarBubble, PropsType } from './BetterAvatarBubble'; +import { setup as setupI18n } from '../../js/modules/i18n'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + children: overrideProps.children, + color: overrideProps.color, + i18n, + isSelected: Boolean(overrideProps.isSelected), + onDelete: action('onDelete'), + onSelect: action('onSelect'), + style: overrideProps.style, +}); + +const story = storiesOf('Components/BetterAvatarBubble', module); + +story.add('Children', () => ( + HI
, + color: AvatarColors[8], + })} + /> +)); + +story.add('Selected', () => ( + +)); + +story.add('Style', () => ( + +)); diff --git a/ts/components/BetterAvatarBubble.tsx b/ts/components/BetterAvatarBubble.tsx new file mode 100644 index 000000000000..e8b49031070a --- /dev/null +++ b/ts/components/BetterAvatarBubble.tsx @@ -0,0 +1,60 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { CSSProperties, MouseEvent, ReactNode } from 'react'; +import classNames from 'classnames'; + +import { AvatarColorType } from '../types/Colors'; +import { LocalizerType } from '../types/Util'; + +export type PropsType = { + children?: ReactNode; + color?: AvatarColorType; + i18n: LocalizerType; + isSelected?: boolean; + onDelete?: (ev: MouseEvent) => unknown; + onSelect: () => unknown; + style?: CSSProperties; +}; + +export const BetterAvatarBubble = ({ + children, + color, + i18n, + isSelected, + onDelete, + onSelect, + style, +}: PropsType): JSX.Element => { + return ( +
{ + if (ev.key === 'Enter') { + onSelect(); + } + }} + onClick={onSelect} + role="button" + style={style} + tabIndex={0} + > + {onDelete && ( +
+ ); +}; diff --git a/ts/components/CallNeedPermissionScreen.tsx b/ts/components/CallNeedPermissionScreen.tsx index 3ef889822397..4cee279ecc43 100644 --- a/ts/components/CallNeedPermissionScreen.tsx +++ b/ts/components/CallNeedPermissionScreen.tsx @@ -3,6 +3,7 @@ import React, { useRef, useEffect } from 'react'; import { LocalizerType } from '../types/Util'; +import { AvatarColors } from '../types/Colors'; import { Avatar } from './Avatar'; import { Intl } from './Intl'; import { ContactName } from './conversation/ContactName'; @@ -46,7 +47,7 @@ export const CallNeedPermissionScreen: React.FC = ({ = ({ = ({ ({ + i18n, + onClose: action('onClose'), + onDiscard: action('onDiscard'), +}); + +const story = storiesOf('Components/ConfirmDiscardDialog', module); + +story.add('Default', () => ); diff --git a/ts/components/ConfirmDiscardDialog.tsx b/ts/components/ConfirmDiscardDialog.tsx new file mode 100644 index 000000000000..38e4e625fe34 --- /dev/null +++ b/ts/components/ConfirmDiscardDialog.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { ConfirmationDialog } from './ConfirmationDialog'; +import { LocalizerType } from '../types/Util'; + +export type PropsType = { + i18n: LocalizerType; + onClose: () => unknown; + onDiscard: () => unknown; +}; + +export const ConfirmDiscardDialog = ({ + i18n, + onClose, + onDiscard, +}: PropsType): JSX.Element | null => { + return ( + + {i18n('ConfirmDiscardDialog--discard')} + + ); +}; diff --git a/ts/components/ContactListItem.stories.tsx b/ts/components/ContactListItem.stories.tsx index af56d85ae91b..b8b3bdc84b50 100644 --- a/ts/components/ContactListItem.stories.tsx +++ b/ts/components/ContactListItem.stories.tsx @@ -10,6 +10,7 @@ import { gifUrl } from '../storybook/Fixtures'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; import { ContactListItem } from './ContactListItem'; +import { getRandomColor } from '../test-both/helpers/getRandomColor'; const i18n = setupI18n('en', enMessages); const onClick = action('onClick'); @@ -126,7 +127,7 @@ storiesOf('Components/ContactListItem', module) isMe={false} title="Someone 🔥 Somewhere" name="Someone 🔥 Somewhere" - color="teal" + color={getRandomColor()} phoneNumber="(202) 555-0011" profileName="🔥Flames🔥" sharedGroupNames={[]} @@ -140,7 +141,7 @@ storiesOf('Components/ContactListItem', module) ; const contacts: Array = times(50, index => getDefaultConversation({ - color: 'crimson', id: `contact-${index}`, name: `Contact ${index}`, phoneNumber: '(202) 555-0001', @@ -37,7 +36,6 @@ const contactPillProps = ( ...(overrideProps || getDefaultConversation({ avatarPath: gifUrl, - color: 'crimson', firstName: 'John', id: 'abc123', isMe: false, diff --git a/ts/components/DirectCallRemoteParticipant.tsx b/ts/components/DirectCallRemoteParticipant.tsx index 85cbd97b7303..952bca12a3be 100644 --- a/ts/components/DirectCallRemoteParticipant.tsx +++ b/ts/components/DirectCallRemoteParticipant.tsx @@ -5,6 +5,7 @@ import React, { useRef, useEffect } from 'react'; import { SetRendererCanvasType } from '../state/ducks/calling'; import { ConversationType } from '../state/ducks/conversations'; import { LocalizerType } from '../types/Util'; +import { AvatarColors } from '../types/Colors'; import { Avatar } from './Avatar'; type PropsType = { @@ -69,7 +70,7 @@ function renderAvatar( = React.memo( { - const color = select('color', AvatarColors, 'ultramarine'); + const color = select('color', AvatarColors, getRandomColor()); const isVideoCall = boolean('isVideoCall', false); const name = text( 'name', diff --git a/ts/components/IncomingCallBar.tsx b/ts/components/IncomingCallBar.tsx index f03baa2f7e0a..48002cd2aa63 100644 --- a/ts/components/IncomingCallBar.tsx +++ b/ts/components/IncomingCallBar.tsx @@ -7,6 +7,7 @@ import { Tooltip } from './Tooltip'; import { Theme } from '../util/theme'; import { ContactName } from './conversation/ContactName'; import { LocalizerType } from '../types/Util'; +import { AvatarColors } from '../types/Colors'; import { ConversationType } from '../state/ducks/conversations'; import { AcceptCallType, DeclineCallType } from '../state/ducks/calling'; @@ -89,7 +90,7 @@ export const IncomingCallBar = ({ = {}): PropsType => ({ closeCantAddContactToGroupModal: action('closeCantAddContactToGroupModal'), closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'), closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'), + composeDeleteAvatarFromDisk: action('composeDeleteAvatarFromDisk'), + composeReplaceAvatar: action('composeReplaceAvatar'), + composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'), createGroup: action('createGroup'), i18n, modeSpecificProps: defaultModeSpecificProps, @@ -135,6 +138,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ 'startNewConversationFromPhoneNumber' ), startSettingGroupMetadata: action('startSettingGroupMetadata'), + toggleComposeEditingAvatar: action('toggleComposeEditingAvatar'), toggleConversationInChooseMembers: action( 'toggleConversationInChooseMembers' ), @@ -528,7 +532,9 @@ story.add('Group Metadata: No Timer', () => ( groupExpireTimer: 0, hasError: false, isCreating: false, + isEditingAvatar: false, selectedContacts: defaultConversations, + userAvatarData: [], }, })} /> @@ -544,7 +550,9 @@ story.add('Group Metadata: Regular Timer', () => ( groupExpireTimer: 24 * 3600, hasError: false, isCreating: false, + isEditingAvatar: false, selectedContacts: defaultConversations, + userAvatarData: [], }, })} /> @@ -560,7 +568,9 @@ story.add('Group Metadata: Custom Timer', () => ( groupExpireTimer: 7 * 3600, hasError: false, isCreating: false, + isEditingAvatar: false, selectedContacts: defaultConversations, + userAvatarData: [], }, })} /> diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 3f4f68fa0a05..672c72c149ac 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -43,6 +43,12 @@ import { missingCaseError } from '../util/missingCaseError'; import { ConversationList } from './ConversationList'; import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; +import { + DeleteAvatarFromDiskActionType, + ReplaceAvatarActionType, + SaveAvatarToDiskActionType, +} from '../types/Avatar'; + export enum LeftPaneMode { Inbox, Search, @@ -105,6 +111,10 @@ export type PropsType = { showChooseGroupMembers: () => void; startSettingGroupMetadata: () => void; toggleConversationInChooseMembers: (conversationId: string) => void; + composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType; + composeReplaceAvatar: ReplaceAvatarActionType; + composeSaveAvatarToDisk: SaveAvatarToDiskActionType; + toggleComposeEditingAvatar: () => unknown; // Render Props renderExpiredBuildDialog: () => JSX.Element; @@ -118,35 +128,39 @@ export type PropsType = { export const LeftPane: React.FC = ({ cantAddContactToGroup, + challengeStatus, clearGroupCreationError, closeCantAddContactToGroupModal, closeMaximumGroupSizeModal, closeRecommendedGroupSizeModal, + composeDeleteAvatarFromDisk, + composeReplaceAvatar, + composeSaveAvatarToDisk, createGroup, i18n, modeSpecificProps, - challengeStatus, - setChallengeStatus, openConversationInternal, + renderCaptchaDialog, renderExpiredBuildDialog, renderMainHeader, renderMessageSearchResult, renderNetworkStatus, renderRelinkDialog, renderUpdateDialog, - renderCaptchaDialog, selectedConversationId, selectedMessageId, - setComposeSearchTerm, + setChallengeStatus, setComposeGroupAvatar, - setComposeGroupName, setComposeGroupExpireTimer, + setComposeGroupName, + setComposeSearchTerm, showArchivedConversations, + showChooseGroupMembers, showInbox, startComposing, - showChooseGroupMembers, startNewConversationFromPhoneNumber, startSettingGroupMetadata, + toggleComposeEditingAvatar, toggleConversationInChooseMembers, }) => { const previousModeSpecificProps = usePrevious( @@ -340,11 +354,15 @@ export const LeftPane: React.FC = ({ closeCantAddContactToGroupModal, closeMaximumGroupSizeModal, closeRecommendedGroupSizeModal, + composeDeleteAvatarFromDisk, + composeReplaceAvatar, + composeSaveAvatarToDisk, createGroup, i18n, setComposeGroupAvatar, setComposeGroupName, setComposeGroupExpireTimer, + toggleComposeEditingAvatar, onChangeComposeSearchTerm: event => { setComposeSearchTerm(event.target.value); }, diff --git a/ts/components/Lightbox.stories.tsx b/ts/components/Lightbox.stories.tsx index dd452b111609..5b8dccef2adf 100644 --- a/ts/components/Lightbox.stories.tsx +++ b/ts/components/Lightbox.stories.tsx @@ -129,3 +129,18 @@ story.add('Including Next/Previous/Save Callbacks', () => { return ; }); + +story.add('Custom children', () => ( + +
+ I am middle child +
+
+)); diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index 1a7c86a76181..ef1f1a89cca5 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -1,7 +1,7 @@ // Copyright 2018-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { ReactNode } from 'react'; import classNames from 'classnames'; import is from '@sindresorhus/is'; @@ -25,6 +25,7 @@ const colorSVG = (url: string, color: string) => { }; export type Props = { + children?: ReactNode; close: () => void; contentType: MIME.MIMEType | undefined; i18n: LocalizerType; @@ -53,6 +54,7 @@ const styles = { top: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.9)', + zIndex: 10, } as React.CSSProperties, buttonContainer: { backgroundColor: 'transparent', @@ -298,6 +300,7 @@ export class Lightbox extends React.Component { public render(): JSX.Element { const { caption, + children, contentType, i18n, isViewOnce, @@ -329,7 +332,7 @@ export class Lightbox extends React.Component { isViewOnce, loop, }) - : null} + : children} {caption ?
{caption}
: null}
diff --git a/ts/components/Modal.stories.tsx b/ts/components/Modal.stories.tsx index 04138001505e..54e0bdbf6a19 100644 --- a/ts/components/Modal.stories.tsx +++ b/ts/components/Modal.stories.tsx @@ -106,3 +106,45 @@ story.add('Long body with long title and X button', () => (

{LOREM_IPSUM}

)); + +story.add('With sticky buttons long body', () => ( + +

{LOREM_IPSUM}

+

{LOREM_IPSUM}

+

{LOREM_IPSUM}

+

{LOREM_IPSUM}

+ + + + +
+)); + +story.add('With sticky buttons short body', () => ( + +

{LOREM_IPSUM.slice(0, 140)}

+ + + + +
+)); + +story.add('Sticky footer, Lots of buttons', () => ( + +

{LOREM_IPSUM}

+ + + + + + + + + +
+)); diff --git a/ts/components/Modal.tsx b/ts/components/Modal.tsx index 882817bd0d6c..2174d7facd88 100644 --- a/ts/components/Modal.tsx +++ b/ts/components/Modal.tsx @@ -1,7 +1,8 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useState, ReactElement, ReactNode } from 'react'; +import React, { useRef, useState, ReactElement, ReactNode } from 'react'; +import Measure, { ContentRect, MeasuredComponentProps } from 'react-measure'; import classNames from 'classnames'; import { noop } from 'lodash'; @@ -13,6 +14,7 @@ import { useHasWrapped } from '../util/hooks'; type PropsType = { children: ReactNode; + hasStickyButtons?: boolean; hasXButton?: boolean; i18n: LocalizerType; moduleClassName?: string; @@ -26,6 +28,7 @@ const BASE_CLASS_NAME = 'module-Modal'; export function Modal({ children, + hasStickyButtons, hasXButton, i18n, moduleClassName, @@ -34,18 +37,32 @@ export function Modal({ title, theme, }: Readonly): ReactElement { + const modalRef = useRef(null); const [scrolled, setScrolled] = useState(false); + const [hasOverflow, setHasOverflow] = useState(false); const hasHeader = Boolean(hasXButton || title); const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName); + function handleResize({ scroll }: ContentRect) { + const modalNode = modalRef?.current; + if (!modalNode) { + return; + } + if (scroll) { + setHasOverflow(scroll.height > modalNode.clientHeight); + } + } + return (
{hasHeader && (
@@ -72,17 +89,25 @@ export function Modal({ )}
)} -
+ {({ measureRef }: MeasuredComponentProps) => ( +
{ + setScrolled((event.target as HTMLDivElement).scrollTop > 2); + }} + ref={measureRef} + > + {children} +
)} - onScroll={event => { - setScrolled((event.target as HTMLDivElement).scrollTop > 2); - }} - > - {children} -
+
); diff --git a/ts/components/ProfileEditor.stories.tsx b/ts/components/ProfileEditor.stories.tsx index 8308a4cac322..9ab8c33550b2 100644 --- a/ts/components/ProfileEditor.stories.tsx +++ b/ts/components/ProfileEditor.stories.tsx @@ -14,6 +14,7 @@ import { getFirstName, getLastName, } from '../test-both/helpers/getDefaultConversation'; +import { getRandomColor } from '../test-both/helpers/getRandomColor'; const i18n = setupI18n('en', enMessages); @@ -23,6 +24,9 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ aboutEmoji: overrideProps.aboutEmoji, aboutText: text('about', overrideProps.aboutText || ''), avatarPath: overrideProps.avatarPath, + conversationId: '123', + color: overrideProps.color || getRandomColor(), + deleteAvatarFromDisk: action('deleteAvatarFromDisk'), familyName: overrideProps.familyName, firstName: text('firstName', overrideProps.firstName || getFirstName()), i18n, @@ -30,7 +34,10 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ onProfileChanged: action('onProfileChanged'), onSetSkinTone: overrideProps.onSetSkinTone || action('onSetSkinTone'), recentEmojis: [], + replaceAvatar: action('replaceAvatar'), + saveAvatarToDisk: action('saveAvatarToDisk'), skinTone: overrideProps.skinTone || 0, + userAvatarData: [], }); stories.add('Full Set', () => { diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index a8d9cadfed12..2e52b0840378 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -3,16 +3,24 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { AvatarInputContainer } from './AvatarInputContainer'; -import { AvatarInputType } from './AvatarInput'; +import { AvatarColors, AvatarColorType } from '../types/Colors'; +import { + AvatarDataType, + DeleteAvatarFromDiskActionType, + ReplaceAvatarActionType, + SaveAvatarToDiskActionType, +} from '../types/Avatar'; +import { AvatarEditor } from './AvatarEditor'; +import { AvatarPreview } from './AvatarPreview'; import { Button, ButtonVariant } from './Button'; -import { ConfirmationDialog } from './ConfirmationDialog'; +import { ConfirmDiscardDialog } from './ConfirmDiscardDialog'; 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 { Modal } from './Modal'; import { PanelRow } from './conversation/conversation-details/PanelRow'; import { ProfileDataType } from '../state/ducks/conversations'; import { getEmojiData, unifiedToEmoji } from './emoji/lib'; @@ -20,6 +28,7 @@ import { missingCaseError } from '../util/missingCaseError'; export enum EditState { None = 'None', + BetterAvatar = 'BetterAvatar', ProfileName = 'ProfileName', Bio = 'Bio', } @@ -28,7 +37,7 @@ type PropsExternalType = { onEditStateChanged: (editState: EditState) => unknown; onProfileChanged: ( profileData: ProfileDataType, - avatarData?: ArrayBuffer + avatarBuffer?: ArrayBuffer ) => unknown; }; @@ -36,13 +45,19 @@ export type PropsDataType = { aboutEmoji?: string; aboutText?: string; avatarPath?: string; + color?: AvatarColorType; + conversationId: string; familyName?: string; firstName: string; i18n: LocalizerType; + userAvatarData: Array; } & Pick; type PropsActionType = { + deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; onSetSkinTone: (tone: number) => unknown; + replaceAvatar: ReplaceAvatarActionType; + saveAvatarToDisk: SaveAvatarToDiskActionType; }; export type PropsType = PropsDataType & PropsActionType & PropsExternalType; @@ -79,6 +94,9 @@ export const ProfileEditor = ({ aboutEmoji, aboutText, avatarPath, + color, + conversationId, + deleteAvatarFromDisk, familyName, firstName, i18n, @@ -86,7 +104,10 @@ export const ProfileEditor = ({ onProfileChanged, onSetSkinTone, recentEmojis, + replaceAvatar, + saveAvatarToDisk, skinTone, + userAvatarData, }: PropsType): JSX.Element => { const focusInputRef = useRef(null); const [editState, setEditState] = useState(EditState.None); @@ -105,7 +126,7 @@ export const ProfileEditor = ({ aboutText, }); - const [avatarData, setAvatarData] = useState( + const [avatarBuffer, setAvatarBuffer] = useState( undefined ); const [stagedProfile, setStagedProfile] = useState({ @@ -115,8 +136,6 @@ export const ProfileEditor = ({ firstName, }); - let content: JSX.Element; - const handleBack = useCallback(() => { setEditState(EditState.None); onEditStateChanged(EditState.None); @@ -135,9 +154,11 @@ export const ProfileEditor = ({ const handleAvatarChanged = useCallback( (avatar: ArrayBuffer | undefined) => { - setAvatarData(avatar); + setAvatarBuffer(avatar); + setEditState(EditState.None); + onProfileChanged(stagedProfile, avatar); }, - [setAvatarData] + [onProfileChanged, stagedProfile] ); const getTextEncoder = useCallback(() => new TextEncoder(), []); @@ -154,6 +175,10 @@ export const ProfileEditor = ({ [countByteLength] ); + const getFullNameText = () => { + return [fullName.firstName, fullName.familyName].filter(Boolean).join(' '); + }; + useEffect(() => { const focusNode = focusInputRef.current; if (!focusNode) { @@ -163,7 +188,34 @@ export const ProfileEditor = ({ focusNode.focus(); }, [editState]); - if (editState === EditState.ProfileName) { + useEffect(() => { + onEditStateChanged(editState); + }, [editState, onEditStateChanged]); + + const handleAvatarLoaded = useCallback(avatar => { + setAvatarBuffer(avatar); + }, []); + + let content: JSX.Element; + + if (editState === EditState.BetterAvatar) { + content = ( + + ); + } else if (editState === EditState.ProfileName) { const shouldDisableSave = !stagedProfile.firstName || (stagedProfile.firstName === fullName.firstName && @@ -200,7 +252,7 @@ export const ProfileEditor = ({ placeholder={i18n('ProfileEditor--last-name')} value={stagedProfile.familyName} /> -
+ -
+ ); } else if (editState === EditState.Bio) { @@ -314,7 +366,7 @@ export const ProfileEditor = ({ /> ))} -
+ -
+ ); } else if (editState === EditState.None) { - const fullNameText = [fullName.firstName, fullName.familyName] - .filter(Boolean) - .join(' '); - content = ( <> - { - handleAvatarChanged(avatar); - onProfileChanged(stagedProfile, avatar); + onAvatarLoaded={handleAvatarLoaded} + onClick={() => { + setEditState(EditState.BetterAvatar); + }} + style={{ + height: 96, + width: 96, }} - onAvatarLoaded={handleAvatarChanged} - type={AvatarInputType.Profile} />
@@ -381,10 +433,9 @@ export const ProfileEditor = ({ icon={ } - label={fullNameText} + label={getFullNameText()} onClick={() => { setEditState(EditState.ProfileName); - onEditStateChanged(EditState.ProfileName); }} /> @@ -402,7 +453,6 @@ export const ProfileEditor = ({ label={fullBio.aboutText || i18n('ProfileEditor--about')} onClick={() => { setEditState(EditState.Bio); - onEditStateChanged(EditState.Bio); }} /> @@ -434,19 +484,11 @@ export const ProfileEditor = ({ return ( <> {confirmDiscardAction && ( - setConfirmDiscardAction(undefined)} - > - {i18n('ProfileEditor--discard')} - + /> )}
{content}
diff --git a/ts/components/ProfileEditorModal.tsx b/ts/components/ProfileEditorModal.tsx index e2fb00e5ce4f..7701f604b577 100644 --- a/ts/components/ProfileEditorModal.tsx +++ b/ts/components/ProfileEditorModal.tsx @@ -18,7 +18,7 @@ export type PropsDataType = { type PropsType = { myProfileChanged: ( profileData: ProfileDataType, - avatarData?: ArrayBuffer + avatarBuffer?: ArrayBuffer ) => unknown; toggleProfileEditor: () => unknown; toggleProfileEditorHasError: () => unknown; @@ -57,6 +57,7 @@ export const ProfileEditorModal = ({ return ( <> { - myProfileChanged(profileData, avatarData); + onProfileChanged={(profileData, avatarBuffer) => { + myProfileChanged(profileData, avatarBuffer); }} onSetSkinTone={onSetSkinTone} /> diff --git a/ts/components/SafetyNumberChangeDialog.stories.tsx b/ts/components/SafetyNumberChangeDialog.stories.tsx index 80421eba216f..bd383f9165dd 100644 --- a/ts/components/SafetyNumberChangeDialog.stories.tsx +++ b/ts/components/SafetyNumberChangeDialog.stories.tsx @@ -15,7 +15,6 @@ const i18n = setupI18n('en', enMessages); const contactWithAllData = getDefaultConversation({ id: 'abc', avatarPath: undefined, - color: 'ultramarine', profileName: '-*Smartest Dude*-', title: 'Rick Sanchez', name: 'Rick Sanchez', @@ -25,7 +24,6 @@ const contactWithAllData = getDefaultConversation({ const contactWithJustProfile = getDefaultConversation({ id: 'def', avatarPath: undefined, - color: 'ultramarine', title: '-*Smartest Dude*-', profileName: '-*Smartest Dude*-', name: undefined, @@ -35,7 +33,6 @@ const contactWithJustProfile = getDefaultConversation({ const contactWithJustNumber = getDefaultConversation({ id: 'xyz', avatarPath: undefined, - color: 'ultramarine', profileName: undefined, name: undefined, title: '(305) 123-4567', @@ -45,7 +42,6 @@ const contactWithJustNumber = getDefaultConversation({ const contactWithNothing = getDefaultConversation({ id: 'some-guid', avatarPath: undefined, - color: 'ultramarine', profileName: undefined, name: undefined, phoneNumber: undefined, diff --git a/ts/components/SafetyNumberViewer.stories.tsx b/ts/components/SafetyNumberViewer.stories.tsx index c67496fda9d7..8448bb2236b1 100644 --- a/ts/components/SafetyNumberViewer.stories.tsx +++ b/ts/components/SafetyNumberViewer.stories.tsx @@ -7,46 +7,43 @@ import { boolean, text } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; import { PropsType, SafetyNumberViewer } from './SafetyNumberViewer'; -import { ConversationType } from '../state/ducks/conversations'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; +import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; const i18n = setupI18n('en', enMessages); -const contactWithAllData = { +const contactWithAllData = getDefaultConversation({ title: 'Summer Smith', name: 'Summer Smith', phoneNumber: '(305) 123-4567', isVerified: true, -} as ConversationType; +}); -const contactWithJustProfile = { +const contactWithJustProfile = getDefaultConversation({ avatarPath: undefined, - color: 'ultramarine', title: '-*Smartest Dude*-', profileName: '-*Smartest Dude*-', name: undefined, phoneNumber: '(305) 123-4567', -} as ConversationType; +}); -const contactWithJustNumber = { +const contactWithJustNumber = getDefaultConversation({ avatarPath: undefined, - color: 'ultramarine', profileName: undefined, name: undefined, title: '(305) 123-4567', phoneNumber: '(305) 123-4567', -} as ConversationType; +}); -const contactWithNothing = { +const contactWithNothing = getDefaultConversation({ id: 'some-guid', avatarPath: undefined, - color: 'ultramarine', profileName: undefined, title: 'Unknown contact', name: undefined, phoneNumber: undefined, -} as ConversationType; +}); const createProps = (overrideProps: Partial = {}): PropsType => ({ contact: overrideProps.contact || contactWithAllData, diff --git a/ts/components/conversation/ContactModal.tsx b/ts/components/conversation/ContactModal.tsx index 31e83bf847f3..1ae8507dcea7 100644 --- a/ts/components/conversation/ContactModal.tsx +++ b/ts/components/conversation/ContactModal.tsx @@ -1,14 +1,15 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { ReactPortal } from 'react'; +import React, { ReactPortal, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { ConversationType } from '../../state/ducks/conversations'; import { About } from './About'; import { Avatar } from '../Avatar'; -import { SharedGroupNames } from '../SharedGroupNames'; +import { AvatarLightbox } from '../AvatarLightbox'; +import { ConversationType } from '../../state/ducks/conversations'; import { LocalizerType } from '../../types/Util'; +import { SharedGroupNames } from '../SharedGroupNames'; export type PropsType = { areWeAdmin: boolean; @@ -41,11 +42,13 @@ export const ContactModal = ({ throw new Error('Contact modal opened without a matching contact'); } - const [root, setRoot] = React.useState(null); - const overlayRef = React.useRef(null); - const closeButtonRef = React.useRef(null); + const [root, setRoot] = useState(null); + const overlayRef = useRef(null); + const closeButtonRef = useRef(null); - React.useEffect(() => { + const [showingAvatar, setShowingAvatar] = useState(false); + + useEffect(() => { const div = document.createElement('div'); document.body.appendChild(div); setRoot(div); @@ -56,18 +59,18 @@ export const ContactModal = ({ }; }, []); - React.useEffect(() => { + useEffect(() => { // Kick off the expensive hydration of the current sharedGroupNames updateSharedGroups(); }, [updateSharedGroups]); - React.useEffect(() => { + useEffect(() => { if (root !== null && closeButtonRef.current) { closeButtonRef.current.focus(); } }, [root]); - React.useEffect(() => { + useEffect(() => { const handler = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); @@ -92,6 +95,115 @@ export const ContactModal = ({ } }; + let content: JSX.Element; + if (showingAvatar) { + content = ( + setShowingAvatar(false)} + /> + ); + } else { + content = ( +
+ + {!contact.isMe && ( + + )} + {!contact.isMe && areWeAdmin && isMember && ( + <> + + + + )} +
+
+ ); + } + return root ? createPortal(
-
- - {!contact.isMe && ( - - )} - {!contact.isMe && areWeAdmin && isMember && ( - <> - - - - )} -
-
+ {content}
, root ) diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index c8b330856362..427d089f3a32 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -7,6 +7,7 @@ import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; +import { getRandomColor } from '../../test-both/helpers/getRandomColor'; import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; import { @@ -71,7 +72,7 @@ const stories: Array = [ title: 'With name and profile, verified', props: { ...commonProps, - color: 'crimson', + color: getRandomColor(), isVerified: true, avatarPath: gifUrl, title: 'Someone 🔥 Somewhere', @@ -87,7 +88,7 @@ const stories: Array = [ title: 'With name, not verified, no avatar', props: { ...commonProps, - color: 'blue', + color: getRandomColor(), isVerified: false, title: 'Someone 🔥 Somewhere', name: 'Someone 🔥 Somewhere', @@ -101,7 +102,7 @@ const stories: Array = [ title: 'With name, not verified, descenders', props: { ...commonProps, - color: 'blue', + color: getRandomColor(), isVerified: false, title: 'Joyrey 🔥 Leppey', name: 'Joyrey 🔥 Leppey', @@ -115,7 +116,7 @@ const stories: Array = [ title: 'Profile, no name', props: { ...commonProps, - color: 'wintergreen', + color: getRandomColor(), isVerified: false, phoneNumber: '(202) 555-0003', type: 'direct', @@ -141,7 +142,7 @@ const stories: Array = [ props: { ...commonProps, showBackButton: true, - color: 'vermilion', + color: getRandomColor(), phoneNumber: '(202) 555-0004', title: '(202) 555-0004', type: 'direct', @@ -153,7 +154,7 @@ const stories: Array = [ title: 'Disappearing messages set', props: { ...commonProps, - color: 'indigo', + color: getRandomColor(), title: '(202) 555-0005', phoneNumber: '(202) 555-0005', type: 'direct', @@ -166,7 +167,7 @@ const stories: Array = [ title: 'Disappearing messages + verified', props: { ...commonProps, - color: 'indigo', + color: getRandomColor(), title: '(202) 555-0005', phoneNumber: '(202) 555-0005', type: 'direct', @@ -181,7 +182,7 @@ const stories: Array = [ title: 'Muting Conversation', props: { ...commonProps, - color: 'ultramarine', + color: getRandomColor(), title: '(202) 555-0006', phoneNumber: '(202) 555-0006', type: 'direct', @@ -194,7 +195,7 @@ const stories: Array = [ title: 'SMS-only conversation', props: { ...commonProps, - color: 'ultramarine', + color: getRandomColor(), title: '(202) 555-0006', phoneNumber: '(202) 555-0006', type: 'direct', @@ -214,7 +215,7 @@ const stories: Array = [ title: 'Basic', props: { ...commonProps, - color: 'ultramarine', + color: getRandomColor(), title: 'Typescript support group', name: 'Typescript support group', phoneNumber: '', @@ -229,7 +230,7 @@ const stories: Array = [ title: 'In a group you left - no disappearing messages', props: { ...commonProps, - color: 'ultramarine', + color: getRandomColor(), title: 'Typescript support group', name: 'Typescript support group', phoneNumber: '', @@ -245,7 +246,7 @@ const stories: Array = [ title: 'In a group with an active group call', props: { ...commonProps, - color: 'ultramarine', + color: getRandomColor(), title: 'Typescript support group', name: 'Typescript support group', phoneNumber: '', @@ -260,7 +261,7 @@ const stories: Array = [ title: 'In a forever muted group', props: { ...commonProps, - color: 'ultramarine', + color: getRandomColor(), title: 'Way too many messages', name: 'Way too many messages', phoneNumber: '', @@ -282,7 +283,7 @@ const stories: Array = [ title: 'In chat with yourself', props: { ...commonProps, - color: 'blue', + color: getRandomColor(), title: '(202) 555-0007', phoneNumber: '(202) 555-0007', id: '15', @@ -302,7 +303,7 @@ const stories: Array = [ title: '1:1 conversation', props: { ...commonProps, - color: 'blue', + color: getRandomColor(), title: '(202) 555-0007', phoneNumber: '(202) 555-0007', id: '16', diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index 5fc5d38477f8..e05623c047cf 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -44,7 +44,6 @@ const createProps = (overrideProps: Partial = {}): Props => ({ contacts: overrideProps.contacts || [ { ...getDefaultConversation({ - color: 'indigo', title: 'Just Max', }), isOutgoingKeyError: false, @@ -124,7 +123,6 @@ story.add('Message Statuses', () => { contacts: [ { ...getDefaultConversation({ - color: 'forest', title: 'Max', }), isOutgoingKeyError: false, @@ -133,7 +131,6 @@ story.add('Message Statuses', () => { }, { ...getDefaultConversation({ - color: 'blue', title: 'Sally', }), isOutgoingKeyError: false, @@ -142,7 +139,6 @@ story.add('Message Statuses', () => { }, { ...getDefaultConversation({ - color: 'burlap', title: 'Terry', }), isOutgoingKeyError: false, @@ -151,7 +147,6 @@ story.add('Message Statuses', () => { }, { ...getDefaultConversation({ - color: 'wintergreen', title: 'Theo', }), isOutgoingKeyError: false, @@ -160,7 +155,6 @@ story.add('Message Statuses', () => { }, { ...getDefaultConversation({ - color: 'steel', title: 'Nikki', }), isOutgoingKeyError: false, @@ -217,7 +211,6 @@ story.add('All Errors', () => { contacts: [ { ...getDefaultConversation({ - color: 'forest', title: 'Max', }), isOutgoingKeyError: true, @@ -226,7 +219,6 @@ story.add('All Errors', () => { }, { ...getDefaultConversation({ - color: 'blue', title: 'Sally', }), errors: [ @@ -241,7 +233,6 @@ story.add('All Errors', () => { }, { ...getDefaultConversation({ - color: 'taupe', title: 'Terry', }), isOutgoingKeyError: true, diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index b0cce695e216..cc1a4fbcebb3 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -15,6 +15,7 @@ import { PropsType, Timeline } from './Timeline'; import { TimelineItem, TimelineItemType } from './TimelineItem'; import { ConversationHero } from './ConversationHero'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; +import { getRandomColor } from '../../test-both/helpers/getRandomColor'; import { LastSeenIndicator } from './LastSeenIndicator'; import { TimelineLoadingRow } from './TimelineLoadingRow'; import { TypingBubble } from './TypingBubble'; @@ -38,7 +39,6 @@ const items: Record = { data: { author: getDefaultConversation({ phoneNumber: '(202) 555-2001', - color: 'forest', }), canDeleteForEveryone: false, canDownload: true, @@ -58,7 +58,7 @@ const items: Record = { 'id-2': { type: 'message', data: { - author: getDefaultConversation({ color: 'forest' }), + author: getDefaultConversation({}), canDeleteForEveryone: false, canDownload: true, canReply: true, @@ -90,7 +90,7 @@ const items: Record = { 'id-3': { type: 'message', data: { - author: getDefaultConversation({ color: 'crimson' }), + author: getDefaultConversation({}), canDeleteForEveryone: false, canDownload: true, canReply: true, @@ -188,7 +188,7 @@ const items: Record = { 'id-10': { type: 'message', data: { - author: getDefaultConversation({ color: 'plum' }), + author: getDefaultConversation({}), canDeleteForEveryone: false, canDownload: true, canReply: true, @@ -208,7 +208,7 @@ const items: Record = { 'id-11': { type: 'message', data: { - author: getDefaultConversation({ color: 'plum' }), + author: getDefaultConversation({}), canDeleteForEveryone: false, canDownload: true, canReply: true, @@ -228,7 +228,7 @@ const items: Record = { 'id-12': { type: 'message', data: { - author: getDefaultConversation({ color: 'crimson' }), + author: getDefaultConversation({}), canDeleteForEveryone: false, canDownload: true, canReply: true, @@ -248,7 +248,7 @@ const items: Record = { 'id-13': { type: 'message', data: { - author: getDefaultConversation({ color: 'blue' }), + author: getDefaultConversation({}), canDeleteForEveryone: false, canDownload: true, canReply: true, @@ -269,7 +269,7 @@ const items: Record = { 'id-14': { type: 'message', data: { - author: getDefaultConversation({ color: 'crimson' }), + author: getDefaultConversation({}), canDeleteForEveryone: false, canDownload: true, canReply: true, @@ -418,7 +418,7 @@ const renderLoadingRow = () => ; const renderTypingBubble = () => ( ({ }, onBlock: action('onBlock'), onLeave: action('onLeave'), + deleteAvatarFromDisk: action('deleteAvatarFromDisk'), + replaceAvatar: action('replaceAvatar'), + saveAvatarToDisk: action('saveAvatarToDisk'), + userAvatarData: [], }); story.add('Basic', () => { diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 2a5ab2cf7da8..9455bf7a25cb 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -32,6 +32,12 @@ import { EditConversationAttributesModal } from './EditConversationAttributesMod import { RequestState } from './util'; import { getCustomColorStyle } from '../../../util/getCustomColorStyle'; import { ConfirmationDialog } from '../../ConfirmationDialog'; +import { + AvatarDataType, + DeleteAvatarFromDiskActionType, + ReplaceAvatarActionType, + SaveAvatarToDiskActionType, +} from '../../../types/Avatar'; enum ModalState { NothingOpen, @@ -73,9 +79,16 @@ export type StateProps = { ) => Promise; onBlock: () => void; onLeave: () => void; + userAvatarData: Array; }; -export type Props = StateProps; +type ActionProps = { + deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; + replaceAvatar: ReplaceAvatarActionType; + saveAvatarToDisk: SaveAvatarToDiskActionType; +}; + +export type Props = StateProps & ActionProps; export const ConversationDetails: React.ComponentType = ({ addMembers, @@ -101,6 +114,10 @@ export const ConversationDetails: React.ComponentType = ({ updateGroupAttributes, onBlock, onLeave, + deleteAvatarFromDisk, + replaceAvatar, + saveAvatarToDisk, + userAvatarData, }) => { const [modalState, setModalState] = useState( ModalState.NothingOpen @@ -141,7 +158,9 @@ export const ConversationDetails: React.ComponentType = ({ case ModalState.EditingGroupTitle: modalNode = ( = ({ }} requestState={editGroupAttributesRequestState} title={conversation.title} + deleteAvatarFromDisk={deleteAvatarFromDisk} + replaceAvatar={replaceAvatar} + saveAvatarToDisk={saveAvatarToDisk} + userAvatarData={userAvatarData} /> ); break; diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx index 2800d54f48f3..533cdddba511 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx @@ -1,14 +1,15 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { ReactNode } from 'react'; +import React, { ReactNode, useState } from 'react'; import { Avatar } from '../../Avatar'; -import { Emojify } from '../Emojify'; -import { LocalizerType } from '../../../types/Util'; +import { AvatarLightbox } from '../../AvatarLightbox'; import { ConversationType } from '../../../state/ducks/conversations'; +import { Emojify } from '../Emojify'; import { GroupDescription } from '../GroupDescription'; import { GroupV2Membership } from './ConversationDetailsMembershipList'; +import { LocalizerType } from '../../../types/Util'; import { bemGenerator } from './util'; export type Props = { @@ -28,6 +29,8 @@ export const ConversationDetailsHeader: React.ComponentType = ({ memberships, startEditing, }) => { + const [showingAvatar, setShowingAvatar] = useState(false); + let subtitle: ReactNode; if (conversation.groupDescription) { subtitle = ( @@ -45,26 +48,41 @@ export const ConversationDetailsHeader: React.ComponentType = ({ ]); } - const contents = ( - <> - -
-
- -
-
- + const avatar = ( + setShowingAvatar(true)} + sharedGroupNames={[]} + /> ); + const contents = ( +
+
+ +
+
+ ); + + const avatarLightbox = showingAvatar ? ( + setShowingAvatar(false)} + /> + ) : null; + if (canEdit) { return (
+ {avatarLightbox} + {avatar} + ); + } + + return ( + + {content} ); }; diff --git a/ts/components/conversationList/CreateNewGroupButton.tsx b/ts/components/conversationList/CreateNewGroupButton.tsx index fde45b2e78fe..d703f1872dcb 100644 --- a/ts/components/conversationList/CreateNewGroupButton.tsx +++ b/ts/components/conversationList/CreateNewGroupButton.tsx @@ -5,6 +5,7 @@ import React, { CSSProperties, FunctionComponent } from 'react'; import { BaseConversationListItem } from './BaseConversationListItem'; import { LocalizerType } from '../../types/Util'; +import { AvatarColors } from '../../types/Colors'; type PropsType = { i18n: LocalizerType; @@ -19,7 +20,7 @@ export const CreateNewGroupButton: FunctionComponent = React.memo( return ( = React.memo( return ( 8) { return ''; diff --git a/ts/components/leftPane/LeftPaneHelper.tsx b/ts/components/leftPane/LeftPaneHelper.tsx index ae12e8599ac9..92168ccd0a25 100644 --- a/ts/components/leftPane/LeftPaneHelper.tsx +++ b/ts/components/leftPane/LeftPaneHelper.tsx @@ -5,6 +5,11 @@ import { ChangeEvent, ReactChild } from 'react'; import { Row } from '../ConversationList'; import { LocalizerType } from '../../types/Util'; +import { + DeleteAvatarFromDiskActionType, + ReplaceAvatarActionType, + SaveAvatarToDiskActionType, +} from '../../types/Avatar'; export enum FindDirection { Up, @@ -46,6 +51,9 @@ export abstract class LeftPaneHelper { closeCantAddContactToGroupModal: () => unknown; closeMaximumGroupSizeModal: () => unknown; closeRecommendedGroupSizeModal: () => unknown; + composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType; + composeReplaceAvatar: ReplaceAvatarActionType; + composeSaveAvatarToDisk: SaveAvatarToDiskActionType; createGroup: () => unknown; i18n: LocalizerType; setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown; @@ -55,6 +63,7 @@ export abstract class LeftPaneHelper { event: ChangeEvent ) => unknown; removeSelectedContact: (_: string) => unknown; + toggleComposeEditingAvatar: () => unknown; }> ): null | ReactChild { return null; diff --git a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx index 2af5b322feed..066d13e4ee2d 100644 --- a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx +++ b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx @@ -8,11 +8,20 @@ import { Row, RowType } from '../ConversationList'; import { PropsDataType as ContactListItemPropsType } from '../conversationList/ContactListItem'; import { DisappearingTimerSelect } from '../DisappearingTimerSelect'; import { LocalizerType } from '../../types/Util'; -import { AvatarInput } from '../AvatarInput'; import { Alert } from '../Alert'; +import { AvatarEditor } from '../AvatarEditor'; +import { AvatarPreview } from '../AvatarPreview'; import { Spinner } from '../Spinner'; import { Button } from '../Button'; +import { Modal } from '../Modal'; import { GroupTitleInput } from '../GroupTitleInput'; +import { + AvatarDataType, + DeleteAvatarFromDiskActionType, + ReplaceAvatarActionType, + SaveAvatarToDiskActionType, +} from '../../types/Avatar'; +import { AvatarColors } from '../../types/Colors'; export type LeftPaneSetGroupMetadataPropsType = { groupAvatar: undefined | ArrayBuffer; @@ -20,7 +29,9 @@ export type LeftPaneSetGroupMetadataPropsType = { groupExpireTimer: number; hasError: boolean; isCreating: boolean; + isEditingAvatar: boolean; selectedContacts: ReadonlyArray; + userAvatarData: ReadonlyArray; }; /* eslint-disable class-methods-use-this */ @@ -36,15 +47,21 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper; + private readonly userAvatarData: ReadonlyArray; + constructor({ groupAvatar, groupName, groupExpireTimer, - isCreating, hasError, + isCreating, + isEditingAvatar, selectedContacts, + userAvatarData, }: Readonly) { super(); @@ -53,7 +70,9 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper unknown; + composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType; + composeReplaceAvatar: ReplaceAvatarActionType; + composeSaveAvatarToDisk: SaveAvatarToDiskActionType; createGroup: () => unknown; i18n: LocalizerType; setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown; setComposeGroupExpireTimer: (_: number) => void; setComposeGroupName: (_: string) => unknown; + toggleComposeEditingAvatar: () => unknown; }>): ReactChild { + const [avatarColor] = AvatarColors; const disabled = this.isCreating; return ( @@ -121,12 +149,43 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper - + { + setComposeGroupAvatar(newAvatar); + toggleComposeEditingAvatar(); + }} + userAvatarData={this.userAvatarData} + replaceAvatar={composeReplaceAvatar} + saveAvatarToDisk={composeSaveAvatarToDisk} + /> + + )} +
; + avatars?: Array; }>): Promise { // Ensure we have the credentials we need before attempting GroupsV2 operations await maybeFetchNewCredentials(); @@ -1681,6 +1684,7 @@ export async function createGroupV2({ active_at: now, addedBy: ourConversationId, avatar: avatarAttribute, + avatars, groupVersion: 2, masterKey, profileSharing: true, diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index a2b68947b75a..8d15cd0345ef 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -24,6 +24,7 @@ import { ConversationColorType } from './types/Colors'; import { AttachmentType, ThumbnailType } from './types/Attachment'; import { ContactType } from './types/Contact'; import { SignalService as Proto } from './protobuf'; +import { AvatarDataType } from './types/Avatar'; import AccessRequiredEnum = Proto.AccessControl.AccessRequired; import MemberRoleEnum = Proto.Member.Role; @@ -299,6 +300,7 @@ export type ConversationAttributesType = { path: string; hash?: string; } | null; + avatars?: Array; description?: string; expireTimer?: number; membersV2?: Array; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 54cb8aac248c..f4b5f223b13a 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -3,7 +3,7 @@ /* eslint-disable class-methods-use-this */ /* eslint-disable camelcase */ -import { compact, sample } from 'lodash'; +import { compact } from 'lodash'; import { ConversationAttributesType, MessageAttributesType, @@ -21,7 +21,6 @@ import { CallbackResultType } from '../textsecure/Types.d'; import { ConversationType } from '../state/ducks/conversations'; import { AvatarColorType, - AvatarColors, ConversationColorType, CustomColorType, DEFAULT_CONVERSATION_COLOR, @@ -82,6 +81,7 @@ import { Reactions, ReactionModel } from '../messageModifiers/Reactions'; import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady'; import { getProfile } from '../util/getProfile'; import { SEALED_SENDER } from '../types/SealedSender'; +import { getAvatarData } from '../util/getAvatarData'; // TODO: remove once we move away from ArrayBuffers const FIXMEU8 = Uint8Array; @@ -311,10 +311,13 @@ export class ConversationModel extends window.Backbone FIVE_MINUTES ); - // Ensure each contact has a an avatar color associated with it - if (!this.get('color')) { - this.set('color', sample(AvatarColors)); - window.Signal.Data.updateConversation(this.attributes); + const migratedColor = this.getColor(); + if (this.get('color') !== migratedColor) { + this.set('color', migratedColor); + // Not saving the conversation here we're hoping it'll be saved elsewhere + // this may cause some color thrashing if Signal is restarted without + // the convo saving. If that is indeed the case and it's too disruptive + // we should add batched saving. } } @@ -1395,6 +1398,7 @@ export class ConversationModel extends window.Backbone ourConversationId && this.isMemberAwaitingApproval(ourConversationId) ), areWeAdmin: this.areWeAdmin(), + avatars: getAvatarData(this.attributes), canChangeTimer: this.canChangeTimer(), canEditGroupInfo: this.canEditGroupInfo(), avatarPath: this.getAbsoluteAvatarPath(), @@ -4673,10 +4677,6 @@ export class ConversationModel extends window.Backbone } getColor(): AvatarColorType { - if (!isDirectConversation(this.attributes)) { - return 'ultramarine'; - } - return migrateColor(this.get('color')); } diff --git a/ts/services/writeProfile.ts b/ts/services/writeProfile.ts index a758a1f3df91..8693a55377e1 100644 --- a/ts/services/writeProfile.ts +++ b/ts/services/writeProfile.ts @@ -10,7 +10,7 @@ import { handleMessageSend } from '../util/handleMessageSend'; export async function writeProfile( conversation: ConversationType, - avatarData?: ArrayBuffer + avatarBuffer?: ArrayBuffer ): Promise { // 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 @@ -32,7 +32,7 @@ export async function writeProfile( const [profileData, encryptedAvatarData] = await encryptProfileData( conversation, - avatarData + avatarBuffer ); const avatarRequestHeaders = await window.textsecure.messaging.putProfile( profileData @@ -47,17 +47,17 @@ export async function writeProfile( path: string; } | undefined; - if (avatarRequestHeaders && encryptedAvatarData && avatarData) { + if (avatarRequestHeaders && encryptedAvatarData && avatarBuffer) { await window.textsecure.messaging.uploadAvatar( avatarRequestHeaders, encryptedAvatarData ); - const hash = await computeHash(avatarData); + const hash = await computeHash(avatarBuffer); if (hash !== avatarHash) { const [path] = await Promise.all([ - window.Signal.Migrations.writeNewAttachmentData(avatarData), + window.Signal.Migrations.writeNewAttachmentData(avatarBuffer), avatarPath ? window.Signal.Migrations.deleteAttachmentData(avatarPath) : undefined, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 9a29439e54bd..d085125e7e2c 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -20,7 +20,7 @@ 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 { assert, strictAssert } from '../../util/assert'; import * as universalExpireTimer from '../../util/universalExpireTimer'; import { trigger } from '../../shims/events'; import { @@ -53,6 +53,9 @@ import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCol import { ContactSpoofingType } from '../../util/contactSpoofing'; import { writeProfile } from '../../services/writeProfile'; import { getMe } from '../selectors/conversations'; +import { AvatarDataType, getDefaultAvatars } from '../../types/Avatar'; +import { getAvatarData } from '../../util/getAvatarData'; +import { isSameAvatarData } from '../../util/isSameAvatarData'; import { NoopActionType } from './noop'; @@ -86,6 +89,7 @@ export type ConversationType = { about?: string; aboutText?: string; aboutEmoji?: string; + avatars?: Array; avatarPath?: string; avatarHash?: string; unblurredAvatarPath?: string; @@ -244,9 +248,11 @@ type ComposerGroupCreationState = { groupAvatar: undefined | ArrayBuffer; groupName: string; groupExpireTimer: number; + isEditingAvatar: boolean; maximumGroupSizeModalState: OneTimeModalState; recommendedGroupSizeModalState: OneTimeModalState; selectedConversationIds: Array; + userAvatarData: Array; }; type ComposerStateType = @@ -325,9 +331,15 @@ export const getConversationCallMode = ( // Actions -const COLOR_SELECTED = 'conversations/COLOR_SELECTED'; const COLORS_CHANGED = 'conversations/COLORS_CHANGED'; +const COLOR_SELECTED = 'conversations/COLOR_SELECTED'; +const COMPOSE_TOGGLE_EDITING_AVATAR = + 'conversations/compose/COMPOSE_TOGGLE_EDITING_AVATAR'; +const COMPOSE_ADD_AVATAR = 'conversations/compose/ADD_AVATAR'; +const COMPOSE_REMOVE_AVATAR = 'conversations/compose/REMOVE_AVATAR'; +const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR'; const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED'; +const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS'; type CantAddContactToGroupActionType = { type: 'CANT_ADD_CONTACT_TO_GROUP'; @@ -373,6 +385,21 @@ export type ColorSelectedActionType = { type: typeof COLOR_SELECTED; payload: ColorSelectedPayloadType; }; +type ComposeDeleteAvatarActionType = { + type: typeof COMPOSE_REMOVE_AVATAR; + payload: AvatarDataType; +}; +type ComposeReplaceAvatarsActionType = { + type: typeof COMPOSE_REPLACE_AVATAR; + payload: { + curr: AvatarDataType; + prev?: AvatarDataType; + }; +}; +type ComposeSaveAvatarActionType = { + type: typeof COMPOSE_ADD_AVATAR; + payload: AvatarDataType; +}; type CustomColorRemovedActionType = { type: typeof CUSTOM_COLOR_REMOVED; payload: { @@ -594,6 +621,9 @@ type SetRecentMediaItemsActionType = { recentMediaItems: Array; }; }; +type ToggleComposeEditingAvatarActionType = { + type: typeof COMPOSE_TOGGLE_EDITING_AVATAR; +}; type StartComposingActionType = { type: 'START_COMPOSING'; }; @@ -615,6 +645,14 @@ export type ToggleConversationInChooseMembersActionType = { maxGroupSize: number; }; }; + +type ReplaceAvatarsActionType = { + type: typeof REPLACE_AVATARS; + payload: { + conversationId: string; + avatars: Array; + }; +}; export type ConversationActionType = | CantAddContactToGroupActionType | ClearChangedMessagesActionType @@ -626,32 +664,36 @@ export type ConversationActionType = | CloseContactSpoofingReviewActionType | CloseMaximumGroupSizeModalActionType | CloseRecommendedGroupSizeModalActionType + | ColorSelectedActionType + | ColorsChangedActionType + | ComposeDeleteAvatarActionType + | ComposeReplaceAvatarsActionType + | ComposeSaveAvatarActionType | ConversationAddedActionType | ConversationChangedActionType | ConversationRemovedActionType | ConversationUnloadedActionType - | ColorsChangedActionType - | ColorSelectedActionType - | CustomColorRemovedActionType | CreateGroupFulfilledActionType | CreateGroupPendingActionType | CreateGroupRejectedActionType + | CustomColorRemovedActionType | MessageChangedActionType | MessageDeletedActionType - | MessagesAddedActionType | MessageSelectedActionType | MessageSizeChangedActionType + | MessagesAddedActionType | MessagesResetActionType | RemoveAllConversationsActionType | RepairNewestMessageActionType | RepairOldestMessageActionType + | ReplaceAvatarsActionType | ReviewGroupMemberNameCollisionActionType | ReviewMessageRequestNameCollisionActionType | ScrollToMessageActionType | SelectedConversationChangedActionType | SetComposeGroupAvatarActionType - | SetComposeGroupNameActionType | SetComposeGroupExpireTimerActionType + | SetComposeGroupNameActionType | SetComposeSearchTermActionType | SetConversationHeaderTitleActionType | SetIsNearBottomActionType @@ -661,37 +703,42 @@ export type ConversationActionType = | SetRecentMediaItemsActionType | SetSelectedConversationPanelDepthActionType | ShowArchivedConversationsActionType + | ShowChooseGroupMembersActionType | ShowInboxActionType | StartComposingActionType - | ShowChooseGroupMembersActionType | StartSettingGroupMetadataActionType | SwitchToAssociatedViewActionType - | ToggleConversationInChooseMembersActionType; + | ToggleConversationInChooseMembersActionType + | ToggleComposeEditingAvatarActionType; // Action Creators export const actions = { cantAddContactToGroup, clearChangedMessages, - clearInvitedConversationsForNewlyCreatedGroup, clearGroupCreationError, + clearInvitedConversationsForNewlyCreatedGroup, clearSelectedMessage, clearUnreadMetrics, closeCantAddContactToGroupModal, closeContactSpoofingReview, - closeRecommendedGroupSizeModal, closeMaximumGroupSizeModal, + closeRecommendedGroupSizeModal, + colorSelected, + composeDeleteAvatarFromDisk, + composeReplaceAvatar, + composeSaveAvatarToDisk, conversationAdded, conversationChanged, conversationRemoved, conversationUnloaded, - colorSelected, createGroup, + deleteAvatarFromDisk, doubleCheckMissingQuoteReference, messageChanged, messageDeleted, - messagesAdded, messageSizeChanged, + messagesAdded, messagesReset, myProfileChanged, openConversationExternal, @@ -700,14 +747,16 @@ export const actions = { removeCustomColorOnConversations, repairNewestMessage, repairOldestMessage, + replaceAvatar, resetAllChatColors, reviewGroupMemberNameCollision, reviewMessageRequestNameCollision, + saveAvatarToDisk, scrollToMessage, selectMessage, setComposeGroupAvatar, - setComposeGroupName, setComposeGroupExpireTimer, + setComposeGroupName, setComposeSearchTerm, setIsNearBottom, setLoadCountdownStart, @@ -717,17 +766,166 @@ export const actions = { setSelectedConversationHeaderTitle, setSelectedConversationPanelDepth, showArchivedConversations, + showChooseGroupMembers, showInbox, startComposing, - showChooseGroupMembers, startNewConversationFromPhoneNumber, startSettingGroupMetadata, toggleConversationInChooseMembers, + toggleComposeEditingAvatar, }; +function filterAvatarData( + avatars: ReadonlyArray, + data: AvatarDataType +): Array { + return avatars.filter(avatarData => !isSameAvatarData(data, avatarData)); +} + +function getNextAvatarId(avatars: Array): number { + return Math.max(...avatars.map(x => Number(x.id))) + 1; +} + +async function getAvatarsAndUpdateConversation( + conversations: ConversationsStateType, + conversationId: string, + getNextAvatarsData: ( + avatars: Array, + nextId: number + ) => Array +): Promise> { + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + throw new Error('No conversation found'); + } + + const { conversationLookup } = conversations; + const conversationAttrs = conversationLookup[conversationId]; + const avatars = + conversationAttrs.avatars || getAvatarData(conversation.attributes); + + const nextAvatarId = getNextAvatarId(avatars); + const nextAvatars = getNextAvatarsData(avatars, nextAvatarId); + // We don't save buffers to the db, but we definitely want it in-memory so + // we don't have to re-generate them. + // + // Mutating here because we don't want to trigger a model change + // because we're updating redux here manually ourselves. Au revoir Backbone! + conversation.attributes.avatars = nextAvatars.map(avatarData => + omit(avatarData, ['buffer']) + ); + await window.Signal.Data.updateConversation(conversation.attributes); + + return nextAvatars; +} + +function deleteAvatarFromDisk( + avatarData: AvatarDataType, + conversationId?: string +): ThunkAction { + return async (dispatch, getState) => { + if (avatarData.imagePath) { + await window.Signal.Migrations.deleteAvatar(avatarData.imagePath); + } else { + window.log.info( + 'No imagePath for avatarData. Removing from userAvatarData, but not disk' + ); + } + + strictAssert(conversationId, 'conversationId not provided'); + + const avatars = await getAvatarsAndUpdateConversation( + getState().conversations, + conversationId, + prevAvatarsData => filterAvatarData(prevAvatarsData, avatarData) + ); + + dispatch({ + type: REPLACE_AVATARS, + payload: { + conversationId, + avatars, + }, + }); + }; +} + +function replaceAvatar( + curr: AvatarDataType, + prev?: AvatarDataType, + conversationId?: string +): ThunkAction { + return async (dispatch, getState) => { + strictAssert(conversationId, 'conversationId not provided'); + + const avatars = await getAvatarsAndUpdateConversation( + getState().conversations, + conversationId, + (prevAvatarsData, nextId) => { + const newAvatarData = { + ...curr, + id: prev?.id ?? nextId, + }; + const existingAvatarsData = prev + ? filterAvatarData(prevAvatarsData, prev) + : prevAvatarsData; + + return [newAvatarData, ...existingAvatarsData]; + } + ); + + dispatch({ + type: REPLACE_AVATARS, + payload: { + conversationId, + avatars, + }, + }); + }; +} + +function saveAvatarToDisk( + avatarData: AvatarDataType, + conversationId?: string +): ThunkAction { + return async (dispatch, getState) => { + if (!avatarData.buffer) { + throw new Error('No avatar ArrayBuffer provided'); + } + + strictAssert(conversationId, 'conversationId not provided'); + + const imagePath = await window.Signal.Migrations.writeNewAvatarData( + avatarData.buffer + ); + + const avatars = await getAvatarsAndUpdateConversation( + getState().conversations, + conversationId, + (prevAvatarsData, id) => { + const newAvatarData = { + ...avatarData, + imagePath, + id, + }; + + return [newAvatarData, ...prevAvatarsData]; + } + ); + + dispatch({ + type: REPLACE_AVATARS, + payload: { + conversationId, + avatars, + }, + }); + }; +} + function myProfileChanged( profileData: ProfileDataType, - avatarData?: ArrayBuffer + avatarBuffer?: ArrayBuffer ): ThunkAction< void, RootStateType, @@ -743,7 +941,7 @@ function myProfileChanged( ...conversation, ...profileData, }, - avatarData + avatarBuffer ); // writeProfile above updates the backbone model which in turn updates @@ -879,6 +1077,66 @@ function colorSelected({ }; } +function toggleComposeEditingAvatar(): ToggleComposeEditingAvatarActionType { + return { + type: COMPOSE_TOGGLE_EDITING_AVATAR, + }; +} + +function composeSaveAvatarToDisk( + avatarData: AvatarDataType +): ThunkAction { + return async dispatch => { + if (!avatarData.buffer) { + throw new Error('No avatar ArrayBuffer provided'); + } + + const imagePath = await window.Signal.Migrations.writeNewAvatarData( + avatarData.buffer + ); + + dispatch({ + type: COMPOSE_ADD_AVATAR, + payload: { + ...avatarData, + imagePath, + }, + }); + }; +} + +function composeDeleteAvatarFromDisk( + avatarData: AvatarDataType +): ThunkAction { + return async dispatch => { + if (avatarData.imagePath) { + await window.Signal.Migrations.deleteAvatar(avatarData.imagePath); + } else { + window.log.info( + 'No imagePath for avatarData. Removing from userAvatarData, but not disk' + ); + } + + dispatch({ + type: COMPOSE_REMOVE_AVATAR, + payload: avatarData, + }); + }; +} + +function composeReplaceAvatar( + curr: AvatarDataType, + prev?: AvatarDataType +): ComposeReplaceAvatarsActionType { + return { + type: COMPOSE_REPLACE_AVATAR, + payload: { + curr, + prev, + }, + }; +} + function cantAddContactToGroup( conversationId: string ): CantAddContactToGroupActionType { @@ -967,6 +1225,9 @@ function createGroup(): ThunkAction< const conversation = await groups.createGroupV2({ name: composer.groupName.trim(), avatar: composer.groupAvatar, + avatars: composer.userAvatarData.map(avatarData => + omit(avatarData, ['buffer']) + ), expireTimer: composer.groupExpireTimer, conversationIds: composer.selectedConversationIds, }); @@ -2421,6 +2682,8 @@ export function reducer( let groupName: string; let groupAvatar: undefined | ArrayBuffer; let groupExpireTimer: number; + let isEditingAvatar = false; + let userAvatarData = getDefaultAvatars(true); switch (state.composer?.step) { case ComposerStep.ChooseGroupMembers: @@ -2433,6 +2696,8 @@ export function reducer( groupName, groupAvatar, groupExpireTimer, + isEditingAvatar, + userAvatarData, } = state.composer); break; default: @@ -2457,6 +2722,8 @@ export function reducer( groupName, groupAvatar, groupExpireTimer, + isEditingAvatar, + userAvatarData, }, }; } @@ -2477,9 +2744,11 @@ export function reducer( 'groupAvatar', 'groupName', 'groupExpireTimer', + 'isEditingAvatar', 'maximumGroupSizeModalState', 'recommendedGroupSizeModalState', 'selectedConversationIds', + 'userAvatarData', ]), }, }; @@ -2574,6 +2843,99 @@ export function reducer( }; } + if (action.type === COMPOSE_TOGGLE_EDITING_AVATAR) { + const { composer } = state; + + switch (composer?.step) { + case ComposerStep.ChooseGroupMembers: + case ComposerStep.SetGroupMetadata: + return { + ...state, + composer: { + ...composer, + isEditingAvatar: !composer?.isEditingAvatar, + }, + }; + default: + assert(false, 'Setting editing avatar at this step is a no-op'); + return state; + } + } + + if (action.type === COMPOSE_ADD_AVATAR) { + const { payload } = action; + const { composer } = state; + + switch (composer?.step) { + case ComposerStep.ChooseGroupMembers: + case ComposerStep.SetGroupMetadata: + return { + ...state, + composer: { + ...composer, + userAvatarData: [ + { + ...payload, + id: getNextAvatarId(composer.userAvatarData), + }, + ...composer.userAvatarData, + ], + }, + }; + default: + assert(false, 'Adding an avatar at this step is a no-op'); + return state; + } + } + + if (action.type === COMPOSE_REMOVE_AVATAR) { + const { payload } = action; + const { composer } = state; + + switch (composer?.step) { + case ComposerStep.ChooseGroupMembers: + case ComposerStep.SetGroupMetadata: + return { + ...state, + composer: { + ...composer, + userAvatarData: filterAvatarData(composer.userAvatarData, payload), + }, + }; + default: + assert(false, 'Removing an avatar at this step is a no-op'); + return state; + } + } + + if (action.type === COMPOSE_REPLACE_AVATAR) { + const { curr, prev } = action.payload; + const { composer } = state; + + switch (composer?.step) { + case ComposerStep.ChooseGroupMembers: + case ComposerStep.SetGroupMetadata: + return { + ...state, + composer: { + ...composer, + userAvatarData: [ + { + ...curr, + id: prev?.id ?? getNextAvatarId(composer.userAvatarData), + }, + ...(prev + ? filterAvatarData(composer.userAvatarData, prev) + : composer.userAvatarData), + ], + }, + }; + default: + assert(false, 'Replacing an avatar at this step is a no-op'); + return state; + } + } + if (action.type === 'SWITCH_TO_ASSOCIATED_VIEW') { const conversation = getOwn( state.conversationLookup, @@ -2718,5 +3080,29 @@ export function reducer( return nextState; } + if (action.type === REPLACE_AVATARS) { + const { conversationLookup } = state; + const { conversationId, avatars } = action.payload; + + const conversation = conversationLookup[conversationId]; + if (!conversation) { + return state; + } + + const changed = { + ...conversation, + avatars, + }; + + return { + ...state, + conversationLookup: { + ...conversationLookup, + [conversationId]: changed, + }, + ...updateConversationLookups(changed, conversation, state), + }; + } + return state; } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 08b0f0920c75..7b4bb8d808eb 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -25,6 +25,7 @@ import { assert } from '../../util/assert'; import { isConversationUnregistered } from '../../util/isConversationUnregistered'; import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConversations'; import { ContactNameColors, ContactNameColorType } from '../../types/Colors'; +import { AvatarDataType } from '../../types/Avatar'; import { isInSystemContacts } from '../../util/isInSystemContacts'; import { isGroupV2 } from '../../util/whatTypeOfConversation'; @@ -189,6 +190,21 @@ export const isCreatingGroup = createSelector( composerState.isCreating ); +export const isEditingAvatar = createSelector( + getComposerState, + (composerState): boolean => + composerState?.step === ComposerStep.SetGroupMetadata && + composerState.isEditingAvatar +); + +export const getComposeAvatarData = createSelector( + getComposerState, + (composerState): ReadonlyArray => + composerState?.step === ComposerStep.SetGroupMetadata + ? composerState.userAvatarData + : [] +); + export const getMessages = createSelector( getConversations, (state: ConversationsStateType): MessageLookupType => { diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 222cbf3269a7..6d14b73f53a1 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { StateType } from '../reducer'; +import { mapDispatchToProps } from '../actions'; import { ConversationDetails, StateProps, @@ -71,9 +72,10 @@ const mapStateToProps = ( i18n: getIntl(state), isAdmin, ...getGroupMemberships(conversation, conversationSelector), + userAvatarData: conversation.avatars || [], }; }; -const smart = connect(mapStateToProps); +const smart = connect(mapStateToProps, mapDispatchToProps); export const SmartConversationDetails = smart(ConversationDetails); diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index c3a75ead4fcf..ad6ec6a4e8b7 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -17,16 +17,17 @@ import { ComposerStep, OneTimeModalState } from '../ducks/conversations'; import { getSearchResults, isSearching } from '../selectors/search'; import { getIntl, getRegionCode } from '../selectors/user'; import { - getFilteredCandidateContactsForNewGroup, getCantAddContactForModal, - getFilteredComposeContacts, - getFilteredComposeGroups, + getComposeAvatarData, getComposeGroupAvatar, - getComposeGroupName, getComposeGroupExpireTimer, + getComposeGroupName, getComposeSelectedContacts, getComposerConversationSearchTerm, getComposerStep, + getFilteredCandidateContactsForNewGroup, + getFilteredComposeContacts, + getFilteredComposeGroups, getLeftPaneLists, getMaximumGroupSizeModalState, getRecommendedGroupSizeModalState, @@ -35,6 +36,7 @@ import { getShowArchived, hasGroupCreationError, isCreatingGroup, + isEditingAvatar, } from '../selectors/conversations'; import { SmartExpiredBuildDialog } from './ExpiredBuildDialog'; @@ -133,7 +135,9 @@ const getModeSpecificProps = ( groupExpireTimer: getComposeGroupExpireTimer(state), hasError: hasGroupCreationError(state), isCreating: isCreatingGroup(state), + isEditingAvatar: isEditingAvatar(state), selectedContacts: getComposeSelectedContacts(state), + userAvatarData: getComposeAvatarData(state), }; default: throw missingCaseError(composerStep); diff --git a/ts/state/smart/ProfileEditorModal.ts b/ts/state/smart/ProfileEditorModal.ts index 4423d19849bc..f1f07b79292e 100644 --- a/ts/state/smart/ProfileEditorModal.ts +++ b/ts/state/smart/ProfileEditorModal.ts @@ -17,9 +17,16 @@ import { selectRecentEmojis } from '../selectors/emojis'; function mapStateToProps( state: StateType ): PropsDataType & ProfileEditorModalPropsType { - const { avatarPath, aboutText, aboutEmoji, firstName, familyName } = getMe( - state - ); + const { + avatarPath, + avatars: userAvatarData = [], + aboutText, + aboutEmoji, + color, + firstName, + familyName, + id: conversationId, + } = getMe(state); const recentEmojis = selectRecentEmojis(state); const skinTone = get(state, ['items', 'skinTone'], 0); @@ -27,12 +34,15 @@ function mapStateToProps( aboutEmoji, aboutText, avatarPath, + color, + conversationId, familyName, firstName: String(firstName), hasError: state.globalModals.profileEditorHasError, i18n: getIntl(state), recentEmojis, skinTone, + userAvatarData, }; } diff --git a/ts/test-both/helpers/getDefaultConversation.ts b/ts/test-both/helpers/getDefaultConversation.ts index d28fdac52643..a567175c07b2 100644 --- a/ts/test-both/helpers/getDefaultConversation.ts +++ b/ts/test-both/helpers/getDefaultConversation.ts @@ -4,6 +4,7 @@ import { v4 as generateUuid } from 'uuid'; import { sample } from 'lodash'; import { ConversationType } from '../../state/ducks/conversations'; +import { getRandomColor } from './getRandomColor'; const FIRST_NAMES = [ 'James', @@ -323,6 +324,7 @@ export function getDefaultConversation( return { acceptedMessageRequest: true, e164: '+1300555000', + color: getRandomColor(), firstName, id: generateUuid(), isGroupV2Capable: true, diff --git a/ts/test-both/helpers/getRandomColor.ts b/ts/test-both/helpers/getRandomColor.ts new file mode 100644 index 000000000000..8456845fe624 --- /dev/null +++ b/ts/test-both/helpers/getRandomColor.ts @@ -0,0 +1,9 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { sample } from 'lodash'; +import { AvatarColors, AvatarColorType } from '../../types/Colors'; + +export function getRandomColor(): AvatarColorType { + return sample(AvatarColors) || AvatarColors[0]; +} diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 3658307243d0..08c64ce810cd 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -45,6 +45,13 @@ import { setup as setupI18n } from '../../../../js/modules/i18n'; import enMessages from '../../../../_locales/en/messages.json'; import { getDefaultConversation } from '../../helpers/getDefaultConversation'; +function getDefaultComposeState() { + return { + isEditingAvatar: false, + userAvatarData: [], + }; +} + describe('both/state/selectors/conversations', () => { const getEmptyRootState = (): StateType => { return rootReducer(undefined, noopAction()); @@ -317,6 +324,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: 'foo', selectedConversationIds: ['abc'], @@ -340,6 +348,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: ['abc'], cantAddContactIdForModal: undefined, @@ -384,6 +393,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: [], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -406,6 +416,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: [], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -447,6 +458,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: [], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -469,6 +481,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: [], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -1078,6 +1091,7 @@ describe('both/state/selectors/conversations', () => { }, }, composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers, searchTerm, selectedConversationIds: ['abc'], @@ -1139,6 +1153,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: undefined, searchTerm: '', groupAvatar: undefined, @@ -1164,6 +1179,7 @@ describe('both/state/selectors/conversations', () => { ...getEmptyState(), conversationLookup: { abc123: conversation }, composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: 'abc123', searchTerm: '', groupAvatar: undefined, @@ -1602,6 +1618,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: undefined, searchTerm: 'to be cleared', groupAvatar: undefined, @@ -1628,6 +1645,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: undefined, searchTerm: 'to be cleared', groupAvatar: undefined, @@ -1654,6 +1672,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: ['abc'], cantAddContactIdForModal: undefined, @@ -1676,6 +1695,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: ['abc'], cantAddContactIdForModal: undefined, @@ -1703,6 +1723,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: ['abc'], cantAddContactIdForModal: undefined, @@ -1737,6 +1758,7 @@ describe('both/state/selectors/conversations', () => { }, }, composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: ['convo-2', 'convo-1'], cantAddContactIdForModal: undefined, diff --git a/ts/test-both/types/Avatar_test.ts b/ts/test-both/types/Avatar_test.ts new file mode 100644 index 000000000000..5f11481379c5 --- /dev/null +++ b/ts/test-both/types/Avatar_test.ts @@ -0,0 +1,27 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { getDefaultAvatars } from '../../types/Avatar'; + +describe('Avatar', () => { + describe('getDefaultAvatars', () => { + it('returns an array of valid avatars for direct conversations', () => { + assert.isNotEmpty(getDefaultAvatars(false)); + }); + + it('returns an array of valid avatars for group conversations', () => { + assert.isNotEmpty(getDefaultAvatars(true)); + }); + + it('defaults to returning avatars for direct conversations', () => { + const defaultResult = getDefaultAvatars(); + const directResult = getDefaultAvatars(false); + const groupResult = getDefaultAvatars(true); + + assert.deepEqual(defaultResult, directResult); + assert.notDeepEqual(defaultResult, groupResult); + }); + }); +}); diff --git a/ts/test-both/util/getAvatarData_test.ts b/ts/test-both/util/getAvatarData_test.ts new file mode 100644 index 000000000000..cda22c887ded --- /dev/null +++ b/ts/test-both/util/getAvatarData_test.ts @@ -0,0 +1,35 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as uuid } from 'uuid'; +import { getRandomColor } from '../helpers/getRandomColor'; + +import { getAvatarData } from '../../util/getAvatarData'; + +describe('getAvatarData', () => { + it('returns existing avatars if present', () => { + const avatars = [ + { + id: uuid(), + color: getRandomColor(), + text: 'Avatar A', + }, + { + id: uuid(), + color: getRandomColor(), + text: 'Avatar B', + }, + ]; + + assert.strictEqual(getAvatarData({ avatars, type: 'private' }), avatars); + assert.strictEqual(getAvatarData({ avatars, type: 'group' }), avatars); + }); + + it('returns a non-empty array if no avatars are provided', () => { + assert.isNotEmpty(getAvatarData({ type: 'private' })); + assert.isNotEmpty(getAvatarData({ type: 'group' })); + assert.isNotEmpty(getAvatarData({ avatars: [], type: 'private' })); + assert.isNotEmpty(getAvatarData({ avatars: [], type: 'group' })); + }); +}); diff --git a/ts/test-electron/models/conversations_test.ts b/ts/test-electron/models/conversations_test.ts index 0a5fe19ddf28..7368b0b24387 100644 --- a/ts/test-electron/models/conversations_test.ts +++ b/ts/test-electron/models/conversations_test.ts @@ -20,6 +20,7 @@ describe('Conversations', () => { // Creating a fake conversation const conversation = new window.Whisper.Conversation({ + avatars: [], id: window.getGuid(), e164: '+15551234567', uuid: window.getGuid(), diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 370e2980ccdd..388e3b406688 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -27,6 +27,7 @@ import { ContactSpoofingType } from '../../../util/contactSpoofing'; import { CallMode } from '../../../types/Calling'; import * as groups from '../../../groups'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; +import { getDefaultAvatars } from '../../../types/Avatar'; const { cantAddContactToGroup, @@ -56,6 +57,13 @@ const { toggleConversationInChooseMembers, } = actions; +function getDefaultComposeState() { + return { + isEditingAvatar: false, + userAvatarData: [], + }; +} + describe('both/state/ducks/conversations', () => { const getEmptyRootState = () => rootReducer(undefined, noopAction()); @@ -451,6 +459,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: undefined, searchTerm: '', groupAvatar: undefined, @@ -477,6 +486,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: [], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -516,6 +526,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: 'abc123', searchTerm: '', groupAvatar: undefined, @@ -567,6 +578,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: 'abc123', searchTerm: '', groupAvatar: undefined, @@ -593,6 +605,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: 'abc123', searchTerm: '', groupAvatar: undefined, @@ -614,6 +627,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: 'abc123', searchTerm: '', groupAvatar: undefined, @@ -637,6 +651,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: 'abc123', searchTerm: '', groupAvatar: undefined, @@ -663,6 +678,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: 'abc123', searchTerm: '', groupAvatar: undefined, @@ -684,6 +700,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: 'abc123', searchTerm: '', groupAvatar: undefined, @@ -706,6 +723,7 @@ describe('both/state/ducks/conversations', () => { const conversationsState = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: ['abc123'], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -758,6 +776,7 @@ describe('both/state/ducks/conversations', () => { sinon.assert.calledWith(createGroupStub, { name: 'Foo Bar Group', avatar: new Uint8Array([1, 2, 3]).buffer, + avatars: [], expireTimer: 0, conversationIds: ['abc123'], }); @@ -1210,6 +1229,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: [], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -1236,6 +1256,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: [], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -1262,6 +1283,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: [], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -1435,6 +1457,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), cantAddContactIdForModal: undefined, searchTerm: 'to be cleared', groupAvatar: undefined, @@ -1460,6 +1483,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: [], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -1523,6 +1547,7 @@ describe('both/state/ducks/conversations', () => { assert.isFalse(result.showArchived); assert.deepEqual(result.composer, { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers, searchTerm: '', selectedConversationIds: [], @@ -1532,6 +1557,7 @@ describe('both/state/ducks/conversations', () => { groupName: '', groupAvatar: undefined, groupExpireTimer: 0, + userAvatarData: getDefaultAvatars(true), }); }); @@ -1539,6 +1565,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: 'foo bar', selectedConversationIds: [], @@ -1560,6 +1587,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: [], cantAddContactIdForModal: undefined, @@ -1577,6 +1605,7 @@ describe('both/state/ducks/conversations', () => { assert.isFalse(result.showArchived); assert.deepEqual(result.composer, { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers, searchTerm: '', selectedConversationIds: [], @@ -1596,6 +1625,7 @@ describe('both/state/ducks/conversations', () => { assert.isFalse(result.showArchived); assert.deepEqual(result.composer, { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers, searchTerm: '', selectedConversationIds: [], @@ -1605,6 +1635,7 @@ describe('both/state/ducks/conversations', () => { groupName: '', groupAvatar: undefined, groupExpireTimer: 0, + userAvatarData: getDefaultAvatars(true), }); }); @@ -1618,6 +1649,7 @@ describe('both/state/ducks/conversations', () => { assert.isFalse(result.showArchived); assert.deepEqual(result.composer, { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers, searchTerm: '', selectedConversationIds: [], @@ -1627,6 +1659,7 @@ describe('both/state/ducks/conversations', () => { groupName: '', groupAvatar: undefined, groupExpireTimer: 0, + userAvatarData: getDefaultAvatars(true), }); }); }); @@ -1636,6 +1669,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: 'foo bar', selectedConversationIds: ['abc', 'def'], @@ -1651,6 +1685,7 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.deepEqual(result.composer, { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata, selectedConversationIds: ['abc', 'def'], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -1667,6 +1702,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: 'foo bar', selectedConversationIds: ['abc', 'def'], @@ -1682,6 +1718,7 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.deepEqual(result.composer, { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata, selectedConversationIds: ['abc', 'def'], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -1698,6 +1735,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.SetGroupMetadata as const, selectedConversationIds: [], recommendedGroupSizeModalState: OneTimeModalState.NeverShown, @@ -1750,6 +1788,7 @@ describe('both/state/ducks/conversations', () => { const zero = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: '', selectedConversationIds: [], @@ -1765,6 +1804,7 @@ describe('both/state/ducks/conversations', () => { const two = reducer(one, getAction('def', one)); assert.deepEqual(two.composer, { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers, searchTerm: '', selectedConversationIds: ['abc', 'def'], @@ -1781,6 +1821,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: '', selectedConversationIds: ['abc', 'def'], @@ -1796,6 +1837,7 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.deepEqual(result.composer, { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers, searchTerm: '', selectedConversationIds: ['def'], @@ -1815,6 +1857,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: '', selectedConversationIds: oldSelectedConversationIds, @@ -1830,6 +1873,7 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.deepEqual(result.composer, { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers, searchTerm: '', selectedConversationIds: [...oldSelectedConversationIds, newUuid], @@ -1849,6 +1893,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: '', selectedConversationIds: oldSelectedConversationIds, @@ -1864,6 +1909,7 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.deepEqual(result.composer, { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers, searchTerm: '', selectedConversationIds: [...oldSelectedConversationIds, newUuid], @@ -1885,6 +1931,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: '', selectedConversationIds: [], @@ -1909,6 +1956,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: '', selectedConversationIds: oldSelectedConversationIds, @@ -1924,6 +1972,7 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.deepEqual(result.composer, { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers, searchTerm: '', selectedConversationIds: [...oldSelectedConversationIds, newUuid], @@ -1943,6 +1992,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: '', selectedConversationIds: oldSelectedConversationIds, @@ -1958,6 +2008,7 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.deepEqual(result.composer, { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers, searchTerm: '', selectedConversationIds: [...oldSelectedConversationIds, newUuid], @@ -1974,6 +2025,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: '', selectedConversationIds: times(1000, () => uuid()), @@ -2002,6 +2054,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: '', selectedConversationIds: [], @@ -2029,6 +2082,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + ...getDefaultComposeState(), step: ComposerStep.ChooseGroupMembers as const, searchTerm: '', selectedConversationIds: [], diff --git a/ts/test-electron/util/canvasToArrayBuffer_test.ts b/ts/test-electron/util/canvasToArrayBuffer_test.ts index 5df574c857b6..b1f533a2e797 100644 --- a/ts/test-electron/util/canvasToArrayBuffer_test.ts +++ b/ts/test-electron/util/canvasToArrayBuffer_test.ts @@ -2,12 +2,15 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import { IMAGE_JPEG, IMAGE_PNG } from '../../types/MIME'; +import { sniffImageMimeType } from '../../util/sniffImageMimeType'; import { canvasToArrayBuffer } from '../../util/canvasToArrayBuffer'; describe('canvasToArrayBuffer', () => { - it('converts a canvas to an ArrayBuffer', async () => { - const canvas = document.createElement('canvas'); + let canvas: HTMLCanvasElement; + beforeEach(() => { + canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 200; @@ -17,11 +20,21 @@ describe('canvasToArrayBuffer', () => { } context.fillStyle = '#ff9900'; context.fillRect(10, 10, 20, 20); + }); + it('converts a canvas to an ArrayBuffer, JPEG by default', async () => { const result = await canvasToArrayBuffer(canvas); + assert.strictEqual(sniffImageMimeType(result), IMAGE_JPEG); + // These are just smoke tests. assert.instanceOf(result, ArrayBuffer); assert.isAtLeast(result.byteLength, 50); }); + + it('can convert a canvas to a PNG ArrayBuffer', async () => { + const result = await canvasToArrayBuffer(canvas, IMAGE_PNG); + + assert.strictEqual(sniffImageMimeType(result), IMAGE_PNG); + }); }); diff --git a/ts/test-electron/util/canvasToBlob_test.ts b/ts/test-electron/util/canvasToBlob_test.ts index 0513915b7f7f..8e7af9a95e4c 100644 --- a/ts/test-electron/util/canvasToBlob_test.ts +++ b/ts/test-electron/util/canvasToBlob_test.ts @@ -2,12 +2,15 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import { IMAGE_JPEG, IMAGE_PNG } from '../../types/MIME'; +import { sniffImageMimeType } from '../../util/sniffImageMimeType'; import { canvasToBlob } from '../../util/canvasToBlob'; describe('canvasToBlob', () => { - it('converts a canvas to an Blob', async () => { - const canvas = document.createElement('canvas'); + let canvas: HTMLCanvasElement; + beforeEach(() => { + canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 200; @@ -17,11 +20,27 @@ describe('canvasToBlob', () => { } context.fillStyle = '#ff9900'; context.fillRect(10, 10, 20, 20); + }); + it('converts a canvas to an Blob, JPEG by default', async () => { const result = await canvasToBlob(canvas); + assert.strictEqual( + sniffImageMimeType(await result.arrayBuffer()), + IMAGE_JPEG + ); + // These are just smoke tests. assert.instanceOf(result, Blob); assert.isAtLeast(result.size, 50); }); + + it('can convert a canvas to a PNG Blob', async () => { + const result = await canvasToBlob(canvas, IMAGE_PNG); + + assert.strictEqual( + sniffImageMimeType(await result.arrayBuffer()), + IMAGE_PNG + ); + }); }); diff --git a/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts index 4fbff026390f..8b3f9d4ffc2d 100644 --- a/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts +++ b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts @@ -8,17 +8,25 @@ import { getDefaultConversation } from '../../../test-both/helpers/getDefaultCon import { LeftPaneSetGroupMetadataHelper } from '../../../components/leftPane/LeftPaneSetGroupMetadataHelper'; +function getComposeState() { + return { + groupAvatar: undefined, + groupExpireTimer: 0, + groupName: '', + hasError: false, + isCreating: false, + isEditingAvatar: false, + selectedContacts: [], + userAvatarData: [], + }; +} + describe('LeftPaneSetGroupMetadataHelper', () => { describe('getBackAction', () => { it('returns the "show composer" action if a request is not active', () => { const showChooseGroupMembers = sinon.fake(); const helper = new LeftPaneSetGroupMetadataHelper({ - groupAvatar: undefined, - groupExpireTimer: 0, - groupName: '', - hasError: false, - isCreating: false, - selectedContacts: [], + ...getComposeState(), }); assert.strictEqual( @@ -29,12 +37,9 @@ describe('LeftPaneSetGroupMetadataHelper', () => { it("returns undefined (i.e., you can't go back) if a request is active", () => { const helper = new LeftPaneSetGroupMetadataHelper({ - groupAvatar: undefined, - groupExpireTimer: 0, + ...getComposeState(), groupName: 'Foo Bar', - hasError: false, isCreating: true, - selectedContacts: [], }); assert.isUndefined( @@ -47,12 +52,7 @@ describe('LeftPaneSetGroupMetadataHelper', () => { it('returns 0 if there are no contacts', () => { assert.strictEqual( new LeftPaneSetGroupMetadataHelper({ - groupAvatar: undefined, - groupExpireTimer: 0, - groupName: '', - hasError: false, - isCreating: false, - selectedContacts: [], + ...getComposeState(), }).getRowCount(), 0 ); @@ -61,11 +61,7 @@ describe('LeftPaneSetGroupMetadataHelper', () => { it('returns the number of candidate contacts + 2 if there are any', () => { assert.strictEqual( new LeftPaneSetGroupMetadataHelper({ - groupAvatar: undefined, - groupExpireTimer: 0, - groupName: '', - hasError: false, - isCreating: false, + ...getComposeState(), selectedContacts: [ getDefaultConversation(), getDefaultConversation(), @@ -80,12 +76,7 @@ describe('LeftPaneSetGroupMetadataHelper', () => { it('returns undefined if there are no contacts', () => { assert.isUndefined( new LeftPaneSetGroupMetadataHelper({ - groupAvatar: undefined, - groupExpireTimer: 0, - groupName: '', - hasError: false, - isCreating: false, - selectedContacts: [], + ...getComposeState(), }).getRow(0) ); }); @@ -96,11 +87,7 @@ describe('LeftPaneSetGroupMetadataHelper', () => { getDefaultConversation(), ]; const helper = new LeftPaneSetGroupMetadataHelper({ - groupAvatar: undefined, - groupExpireTimer: 0, - groupName: '', - hasError: false, - isCreating: false, + ...getComposeState(), selectedContacts, }); diff --git a/ts/types/Avatar.ts b/ts/types/Avatar.ts new file mode 100644 index 000000000000..da1a289ef7c9 --- /dev/null +++ b/ts/types/Avatar.ts @@ -0,0 +1,122 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { AvatarColorType } from './Colors'; +import { strictAssert } from '../util/assert'; + +export const PersonalAvatarIcons = [ + 'abstract_01', + 'abstract_02', + 'abstract_03', + 'cat', + 'dog', + 'fox', + 'tucan', + 'pig', + 'dinosour', + 'sloth', + 'incognito', + 'ghost', +] as const; + +export const GroupAvatarIcons = [ + 'balloon', + 'book', + 'briefcase', + 'celebration', + 'drink', + 'football', + 'heart', + 'house', + 'melon', + 'soccerball', + 'sunset', + 'surfboard', +] as const; + +type GroupAvatarIconType = typeof GroupAvatarIcons[number]; + +type PersonalAvatarIconType = typeof PersonalAvatarIcons[number]; + +export type AvatarIconType = GroupAvatarIconType | PersonalAvatarIconType; + +export type AvatarDataType = { + id: number | string; + buffer?: ArrayBuffer; + color?: AvatarColorType; + icon?: AvatarIconType; + imagePath?: string; + text?: string; +}; + +export type DeleteAvatarFromDiskActionType = ( + avatarData: AvatarDataType, + conversationId?: string +) => unknown; + +export type ReplaceAvatarActionType = ( + curr: AvatarDataType, + prev?: AvatarDataType, + conversationId?: string +) => unknown; + +export type SaveAvatarToDiskActionType = ( + avatarData: AvatarDataType, + conversationId?: string +) => unknown; + +const groupIconColors = [ + 'A180', + 'A120', + 'A110', + 'A170', + 'A100', + 'A210', + 'A100', + 'A180', + 'A120', + 'A110', + 'A130', + 'A210', +]; + +const personalIconColors = [ + 'A130', + 'A120', + 'A170', + 'A190', + 'A140', + 'A190', + 'A120', + 'A160', + 'A130', + 'A180', + 'A210', + 'A100', +]; + +strictAssert( + groupIconColors.length === GroupAvatarIcons.length && + personalIconColors.length === PersonalAvatarIcons.length, + 'colors.length !== icons.length' +); + +const groupDefaultAvatars = GroupAvatarIcons.map((icon, index) => ({ + id: index, + color: groupIconColors[index], + icon, +})); + +const personalDefaultAvatars = PersonalAvatarIcons.map((icon, index) => ({ + id: index, + color: personalIconColors[index], + icon, +})); + +export function getDefaultAvatars(isGroup?: boolean): Array { + if (isGroup) { + return groupDefaultAvatars; + } + + return personalDefaultAvatars; +} diff --git a/ts/types/Colors.ts b/ts/types/Colors.ts index 25a7fd8955a9..841b5abeebe5 100644 --- a/ts/types/Colors.ts +++ b/ts/types/Colors.ts @@ -1,21 +1,94 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -export const AvatarColors = [ - 'crimson', - 'vermilion', - 'burlap', - 'forest', - 'wintergreen', - 'teal', - 'blue', - 'indigo', - 'violet', - 'plum', - 'taupe', - 'steel', - 'ultramarine', -] as const; +export const AvatarColorMap = new Map([ + [ + 'A100', + { + bg: '#e3e3fe', + fg: '#3838f5', + }, + ], + [ + 'A110', + { + bg: '#dde7fc', + fg: '#1251d3', + }, + ], + [ + 'A120', + { + bg: '#d8e8f0', + fg: '#086da0', + }, + ], + [ + 'A130', + { + bg: '#cde4cd', + fg: '#067906', + }, + ], + [ + 'A140', + { + bg: '#eae0fd', + fg: '#661aff', + }, + ], + [ + 'A150', + { + bg: '#f5e3fe', + fg: '#9f00f0', + }, + ], + [ + 'A160', + { + bg: '#f6d8ec', + fg: '#b8057c', + }, + ], + [ + 'A170', + { + bg: '#f5d7d7', + fg: '#be0404', + }, + ], + [ + 'A180', + { + bg: '#fef5d0', + fg: '#836b01', + }, + ], + [ + 'A190', + { + bg: '#eae6d5', + fg: '#7d6f40', + }, + ], + [ + 'A200', + { + bg: '#d2d2dc', + fg: '#4f4f6d', + }, + ], + [ + 'A210', + { + bg: '#d7d7d9', + fg: '#5c5c5c', + }, + ], +]); + +export const AvatarColors = Array.from(AvatarColorMap.keys()); export const ConversationColors = [ 'ultramarine', @@ -90,6 +163,7 @@ export type CustomColorType = { }; export type AvatarColorType = typeof AvatarColors[number]; + export type ConversationColorType = | typeof ConversationColors[number] | 'custom'; diff --git a/ts/util/avatarDataToArrayBuffer.ts b/ts/util/avatarDataToArrayBuffer.ts new file mode 100644 index 000000000000..3878f1feb945 --- /dev/null +++ b/ts/util/avatarDataToArrayBuffer.ts @@ -0,0 +1,121 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { AvatarColorMap, AvatarColorType } from '../types/Colors'; +import { AvatarDataType } from '../types/Avatar'; +import { canvasToArrayBuffer } from './canvasToArrayBuffer'; +import { getFittedFontSize } from './avatarTextSizeCalculator'; + +const CANVAS_SIZE = 1024; + +function getAvatarColor(color: AvatarColorType): { bg: string; fg: string } { + return AvatarColorMap.get(color) || { bg: 'black', fg: 'white' }; +} + +function setCanvasBackground( + bg: string, + context: CanvasRenderingContext2D, + canvas: HTMLCanvasElement +): void { + context.fillStyle = bg; + context.fillRect(0, 0, canvas.width, canvas.height); +} + +async function drawImage( + src: string, + context: CanvasRenderingContext2D, + canvas: HTMLCanvasElement +): Promise { + const image = new Image(); + image.src = src; + await image.decode(); + // eslint-disable-next-line no-param-reassign + canvas.width = image.width; + // eslint-disable-next-line no-param-reassign + canvas.height = image.height; + context.drawImage(image, 0, 0); +} + +async function getFont(text: string): Promise { + const font = new window.FontFace( + 'Inter', + 'url("fonts/inter-v3.10/Inter-Regular.woff2")' + ); + await font.load(); + + const measurerCanvas = document.createElement('canvas'); + measurerCanvas.width = CANVAS_SIZE; + measurerCanvas.height = CANVAS_SIZE; + + const measurerContext = measurerCanvas.getContext('2d'); + if (!measurerContext) { + throw new Error('getFont: could not get canvas rendering context'); + } + + const fontSize = getFittedFontSize(CANVAS_SIZE, text, candidateFontSize => { + const candidateFont = `${candidateFontSize}px Inter`; + measurerContext.font = candidateFont; + + const { + actualBoundingBoxLeft, + actualBoundingBoxRight, + actualBoundingBoxAscent, + actualBoundingBoxDescent, + } = measurerContext.measureText(text); + + const width = + Math.abs(actualBoundingBoxLeft) + Math.abs(actualBoundingBoxRight); + const height = + Math.abs(actualBoundingBoxAscent) + Math.abs(actualBoundingBoxDescent); + + return { height, width }; + }); + + return `${fontSize}px Inter`; +} + +export async function avatarDataToArrayBuffer( + avatarData: AvatarDataType +): Promise { + const canvas = document.createElement('canvas'); + canvas.width = CANVAS_SIZE; + canvas.height = CANVAS_SIZE; + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error( + 'avatarDataToArrayBuffer: could not get canvas rendering context' + ); + } + + const { color, icon, imagePath, text } = avatarData; + + if (imagePath) { + await drawImage( + window.Signal?.Migrations + ? window.Signal.Migrations.getAbsoluteAvatarPath(imagePath) + : imagePath, + context, + canvas + ); + } else if (color && text) { + const { bg, fg } = getAvatarColor(color); + const textToWrite = text.toLocaleUpperCase(); + + setCanvasBackground(bg, context, canvas); + context.fillStyle = fg; + const font = await getFont(textToWrite); + context.font = font; + context.textBaseline = 'middle'; + context.textAlign = 'center'; + context.fillText(textToWrite, CANVAS_SIZE / 2, CANVAS_SIZE / 2 + 30); + } else if (color && icon) { + const iconPath = `images/avatars/avatar_${icon}.svg`; + await drawImage(iconPath, context, canvas); + context.globalCompositeOperation = 'destination-over'; + const { bg } = getAvatarColor(color); + setCanvasBackground(bg, context, canvas); + } + + return canvasToArrayBuffer(canvas); +} diff --git a/ts/util/avatarTextSizeCalculator.ts b/ts/util/avatarTextSizeCalculator.ts new file mode 100644 index 000000000000..095b7d16c8c5 --- /dev/null +++ b/ts/util/avatarTextSizeCalculator.ts @@ -0,0 +1,52 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as grapheme from './grapheme'; +import { getEmojiCount } from '../components/emoji/lib'; + +type FontSizes = { + diameter: number; + singleEmoji: number; + smol: number; + text: number; +}; + +type RectSize = { + height: number; + width: number; +}; + +export function getFontSizes(bubbleSize: number): FontSizes { + return { + diameter: Math.ceil(bubbleSize * 0.75), + singleEmoji: Math.ceil(bubbleSize * 0.6), + smol: Math.ceil(bubbleSize * 0.05), + text: Math.ceil(bubbleSize * 0.45), + }; +} + +export function getFittedFontSize( + bubbleSize: number, + text: string, + measure: (candidateFontSize: number) => RectSize +): number { + const sizes = getFontSizes(bubbleSize); + + let candidateFontSize = sizes.text; + if (grapheme.count(text) === 1 && getEmojiCount(text) === 1) { + candidateFontSize = sizes.singleEmoji; + } + + for ( + candidateFontSize; + candidateFontSize >= sizes.smol; + candidateFontSize -= 1 + ) { + const { height, width } = measure(candidateFontSize); + if (width < sizes.diameter && height < sizes.diameter) { + return candidateFontSize; + } + } + + return candidateFontSize; +} diff --git a/ts/util/canvasToArrayBuffer.ts b/ts/util/canvasToArrayBuffer.ts index 33537ad08309..06bccb244dca 100644 --- a/ts/util/canvasToArrayBuffer.ts +++ b/ts/util/canvasToArrayBuffer.ts @@ -2,10 +2,13 @@ // SPDX-License-Identifier: AGPL-3.0-only import { canvasToBlob } from './canvasToBlob'; +import { MIMEType } from '../types/MIME'; export async function canvasToArrayBuffer( - canvas: HTMLCanvasElement + canvas: HTMLCanvasElement, + mimeType?: MIMEType, + quality?: number ): Promise { - const blob = await canvasToBlob(canvas); + const blob = await canvasToBlob(canvas, mimeType, quality); return blob.arrayBuffer(); } diff --git a/ts/util/createAvatarData.ts b/ts/util/createAvatarData.ts new file mode 100644 index 000000000000..073c2aa927f9 --- /dev/null +++ b/ts/util/createAvatarData.ts @@ -0,0 +1,14 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { v4 as uuid } from 'uuid'; +import { AvatarDataType } from '../types/Avatar'; + +export function createAvatarData( + partialAvatarData: Readonly> +): AvatarDataType { + return { + id: uuid(), + ...partialAvatarData, + }; +} diff --git a/ts/util/encryptProfileData.ts b/ts/util/encryptProfileData.ts index 947c344b3daf..e9fe9ee76eaf 100644 --- a/ts/util/encryptProfileData.ts +++ b/ts/util/encryptProfileData.ts @@ -16,7 +16,7 @@ const { encryptProfile, encryptProfileItemWithPadding } = Crypto; export async function encryptProfileData( conversation: ConversationType, - avatarData?: ArrayBuffer + avatarBuffer?: ArrayBuffer ): Promise<[ProfileRequestDataType, ArrayBuffer | undefined]> { const { aboutEmoji, @@ -59,7 +59,7 @@ export async function encryptProfileData( PaddedLengths.AboutEmoji ) : null, - avatarData ? encryptProfile(avatarData, keyBuffer) : undefined, + avatarBuffer ? encryptProfile(avatarBuffer, keyBuffer) : undefined, ]); const profileData = { @@ -68,7 +68,7 @@ export async function encryptProfileData( about: bytesAbout ? arrayBufferToBase64(bytesAbout) : null, aboutEmoji: bytesAboutEmoji ? arrayBufferToBase64(bytesAboutEmoji) : null, paymentAddress: window.storage.get('paymentAddress') || null, - avatar: Boolean(avatarData), + avatar: Boolean(avatarBuffer), commitment: deriveProfileKeyCommitment(profileKey, uuid), }; diff --git a/ts/util/getAvatarData.ts b/ts/util/getAvatarData.ts new file mode 100644 index 000000000000..0aca8d276aa7 --- /dev/null +++ b/ts/util/getAvatarData.ts @@ -0,0 +1,20 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { AvatarDataType, getDefaultAvatars } from '../types/Avatar'; +import { isDirectConversation } from './whatTypeOfConversation'; +import { ConversationAttributesType } from '../model-types.d'; + +export function getAvatarData( + conversationAttrs: Pick +): Array { + const { avatars } = conversationAttrs; + + if (avatars && avatars.length) { + return avatars; + } + + const isGroup = !isDirectConversation(conversationAttrs); + + return getDefaultAvatars(isGroup); +} diff --git a/ts/util/isSameAvatarData.ts b/ts/util/isSameAvatarData.ts new file mode 100644 index 000000000000..c1744cfcb63f --- /dev/null +++ b/ts/util/isSameAvatarData.ts @@ -0,0 +1,20 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { AvatarDataType } from '../types/Avatar'; + +export function isSameAvatarData( + a?: AvatarDataType, + b?: AvatarDataType +): boolean { + if (!a || !b) { + return false; + } + if (a.buffer && b.buffer) { + return a.buffer === b.buffer; + } + if (a.imagePath && b.imagePath) { + return a.imagePath === b.imagePath; + } + return a.id === b.id; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 3a34ec8271b4..581abf988491 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -13221,47 +13221,75 @@ }, { "rule": "React-useRef", - "path": "ts/components/AvatarInput.js", + "path": "ts/components/AvatarPreview.js", + "line": " const startingAvatarPathRef = react_1.useRef(avatarValue ? undefined : avatarPath);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-03T21:17:38.615Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/AvatarPreview.tsx", + "line": " const startingAvatarPathRef = useRef(", + "reasonCategory": "usageTrusted", + "updated": "2021-08-03T21:17:38.615Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/AvatarTextEditor.js", + "line": " const measureElRef = react_1.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-04T18:18:09.236Z", + "reasonDetail": "Only used for measurement. Doesn't modify the DOM." + }, + { + "rule": "React-useRef", + "path": "ts/components/AvatarTextEditor.js", + "line": " const inputRef = react_1.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-04T22:02:17.074Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/AvatarTextEditor.js", + "line": " const onDoneRef = react_1.useRef(onDone);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-05T23:40:55.699Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/AvatarTextEditor.tsx", + "line": " const measureElRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-04T18:18:09.236Z", + "reasonDetail": "Only used for measurement. Doesn't modify the DOM." + }, + { + "rule": "React-useRef", + "path": "ts/components/AvatarTextEditor.tsx", + "line": " const inputRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-04T22:02:17.074Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/AvatarTextEditor.tsx", + "line": " const onDoneRef = useRef(onDone);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-05T23:40:55.699Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/AvatarUploadButton.js", "line": " const fileInputRef = react_1.useRef(null);", "reasonCategory": "usageTrusted", - "updated": "2021-03-01T18:34:36.638Z", - "reasonDetail": "Needed to trigger a click on a 'file' input. Doesn't update the DOM." + "updated": "2021-08-03T21:17:38.615Z" }, { "rule": "React-useRef", - "path": "ts/components/AvatarInput.js", - "line": " const menuTriggerRef = react_1.useRef(null);", - "reasonCategory": "usageTrusted", - "updated": "2021-03-01T18:34:36.638Z", - "reasonDetail": "Used to reference popup menu" - }, - { - "rule": "React-useRef", - "path": "ts/components/AvatarInput.tsx", + "path": "ts/components/AvatarUploadButton.tsx", "line": " const fileInputRef = useRef(null);", "reasonCategory": "usageTrusted", - "updated": "2021-07-30T16:57:33.618Z" - }, - { - "rule": "React-useRef", - "path": "ts/components/AvatarInput.tsx", - "line": " const menuTriggerRef = useRef(null);", - "reasonCategory": "usageTrusted", - "updated": "2021-07-30T16:57:33.618Z" - }, - { - "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/AvatarInputContainer.tsx", - "line": " const startingAvatarPathRef = useRef(avatarPath);", - "reasonCategory": "usageTrusted", - "updated": "2021-07-30T16:57:33.618Z" + "updated": "2021-08-03T21:17:38.615Z" }, { "rule": "React-useRef", @@ -13847,6 +13875,20 @@ "updated": "2020-02-14T20:02:37.507Z", "reasonDetail": "Used only to set focus" }, + { + "rule": "React-useRef", + "path": "ts/components/Modal.js", + "line": " const modalRef = react_1.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-05T00:22:31.660Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/Modal.tsx", + "line": " const modalRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-05T00:22:31.660Z" + }, { "rule": "React-useRef", "path": "ts/components/ProfileEditor.js", @@ -13929,30 +13971,30 @@ { "rule": "React-useRef", "path": "ts/components/conversation/ContactModal.js", - "line": " const overlayRef = react_1.default.useRef(null);", + "line": " const overlayRef = react_1.useRef(null);", "reasonCategory": "usageTrusted", - "updated": "2020-11-09T17:48:12.173Z" + "updated": "2021-08-03T21:17:38.615Z" }, { "rule": "React-useRef", "path": "ts/components/conversation/ContactModal.js", - "line": " const closeButtonRef = react_1.default.useRef(null);", + "line": " const closeButtonRef = react_1.useRef(null);", "reasonCategory": "usageTrusted", - "updated": "2020-11-10T21:27:04.909Z" + "updated": "2021-08-03T21:17:38.615Z" }, { "rule": "React-useRef", "path": "ts/components/conversation/ContactModal.tsx", - "line": " const overlayRef = React.useRef(null);", + "line": " const overlayRef = useRef(null);", "reasonCategory": "usageTrusted", - "updated": "2021-07-30T16:57:33.618Z" + "updated": "2021-08-03T21:17:38.615Z" }, { "rule": "React-useRef", "path": "ts/components/conversation/ContactModal.tsx", - "line": " const closeButtonRef = React.useRef(null);", + "line": " const closeButtonRef = useRef(null);", "reasonCategory": "usageTrusted", - "updated": "2021-07-30T16:57:33.618Z" + "updated": "2021-08-03T21:17:38.615Z" }, { "rule": "React-createRef", @@ -14382,6 +14424,20 @@ "reasonCategory": "falseMatch", "updated": "2019-04-26T17:48:30.675Z" }, + { + "rule": "jQuery-load(", + "path": "ts/util/avatarDataToArrayBuffer.js", + "line": " await font.load();", + "reasonCategory": "usageTrusted", + "updated": "2021-08-03T21:17:38.615Z" + }, + { + "rule": "jQuery-load(", + "path": "ts/util/avatarDataToArrayBuffer.ts", + "line": " await font.load();", + "reasonCategory": "usageTrusted", + "updated": "2021-08-03T21:17:38.615Z" + }, { "rule": "React-useRef", "path": "ts/util/hooks/index.js", diff --git a/ts/util/migrateColor.ts b/ts/util/migrateColor.ts index b0181edea281..2385184c13ae 100644 --- a/ts/util/migrateColor.ts +++ b/ts/util/migrateColor.ts @@ -1,53 +1,15 @@ // Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { AvatarColorType } from '../types/Colors'; +import { sample } from 'lodash'; +import { AvatarColors, AvatarColorType } from '../types/Colors'; + +const NEW_COLOR_NAMES = new Set(AvatarColors); export function migrateColor(color?: string): AvatarColorType { - switch (color) { - // These colors no longer exist - case 'orange': - case 'amber': - return 'vermilion'; - case 'yellow': - return 'burlap'; - case 'deep_purple': - return 'violet'; - case 'light_blue': - return 'blue'; - case 'cyan': - return 'teal'; - case 'lime': - return 'wintergreen'; - - // Actual color names - case 'red': - return 'crimson'; - case 'deep_orange': - return 'vermilion'; - case 'brown': - return 'burlap'; - case 'pink': - return 'plum'; - case 'purple': - return 'violet'; - case 'green': - return 'forest'; - case 'light_green': - return 'wintergreen'; - case 'blue_grey': - return 'steel'; - case 'grey': - return 'steel'; - - // These can stay as they are - case 'blue': - case 'indigo': - case 'teal': - case 'ultramarine': - return color; - - default: - return 'steel'; + if (color && NEW_COLOR_NAMES.has(color)) { + return color; } + + return sample(AvatarColors) || AvatarColors[0]; } diff --git a/ts/util/processImageFile.ts b/ts/util/processImageFile.ts new file mode 100644 index 000000000000..b6ad7737ea30 --- /dev/null +++ b/ts/util/processImageFile.ts @@ -0,0 +1,30 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import loadImage, { LoadImageOptions } from 'blueimp-load-image'; +import { canvasToArrayBuffer } from './canvasToArrayBuffer'; + +export async function processImageFile(file: File): Promise { + const { image } = await loadImage(file, { + canvas: true, + cover: true, + crop: true, + imageSmoothingQuality: 'medium', + maxHeight: 512, + maxWidth: 512, + minHeight: 2, + minWidth: 2, + // `imageSmoothingQuality` is not present in `loadImage`'s types, but it is + // documented and supported. Updating DefinitelyTyped is the long-term solution + // here. + } as LoadImageOptions); + + // NOTE: The types for `loadImage` say this can never be a canvas, but it will be if + // `canvas: true`, at least in our case. Again, updating DefinitelyTyped should + // address this. + if (!(image instanceof HTMLCanvasElement)) { + throw new Error('Loaded image was not a canvas'); + } + + return canvasToArrayBuffer(image); +} diff --git a/ts/util/whatTypeOfConversation.ts b/ts/util/whatTypeOfConversation.ts index db818837c220..c3e35c9b61e2 100644 --- a/ts/util/whatTypeOfConversation.ts +++ b/ts/util/whatTypeOfConversation.ts @@ -12,7 +12,7 @@ export enum ConversationTypes { } export function isDirectConversation( - conversationAttrs: ConversationAttributesType + conversationAttrs: Pick ): boolean { return conversationAttrs.type === 'private'; } diff --git a/ts/window.d.ts b/ts/window.d.ts index c249a981e0e4..706b258a83af 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -135,12 +135,27 @@ type ConfirmationDialogViewProps = { resolve: () => void; }; +// This is the subset of `window.FontFace` that we need. We should delete this after +// upgrading to TypeScript 4.4, which will include a full declaration in [its official +// DOM type definitions][0]. +// +// [0]: https://github.com/microsoft/TypeScript/blob/03dff41c9f2038f66fb358e5c23ebd7271145978/lib/lib.dom.d.ts#L5343-L5364 +declare class FontFace { + constructor( + family: string, + source: string | ArrayBuffer | ArrayBufferView, + descriptors?: unknown + ); + load(): Promise; +} + declare global { // We want to extend `window`'s properties, so we need an interface. // eslint-disable-next-line no-restricted-syntax interface Window { startApp: () => void; + FontFace: typeof FontFace; _: typeof Underscore; $: typeof jQuery; @@ -339,6 +354,9 @@ declare global { readDraftData: any; saveAttachmentToDisk: any; writeNewDraftData: any; + deleteAvatar: (path: string) => Promise; + getAbsoluteAvatarPath: (src: string) => string; + writeNewAvatarData: (data: ArrayBuffer) => Promise; }; Types: { Attachment: typeof Attachment;