From b0653d06fe58a543fe2943903f1346819e8fdb4c Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Wed, 26 Mar 2025 12:35:32 -0700 Subject: [PATCH] Fun picker improvements --- _locales/en/messages.json | 68 ++++++ package.json | 7 +- stylesheets/_emoji.scss | 96 -------- stylesheets/_modules.scss | 54 ----- .../components/CallingReactionsToasts.scss | 2 +- stylesheets/components/CompositionInput.scss | 6 - .../components/DraftGifMessageSendModal.scss | 8 + .../components/ReactionPickerPicker.scss | 6 +- stylesheets/components/fun/Fun.scss | 3 + stylesheets/components/fun/FunEmoji.scss | 209 +++++++++++++++-- stylesheets/components/fun/FunGif.scss | 62 +++++ stylesheets/components/fun/FunGrid.scss | 2 +- stylesheets/components/fun/FunItem.scss | 15 +- stylesheets/components/fun/FunLightbox.scss | 33 +++ stylesheets/components/fun/FunSearch.scss | 51 +++- stylesheets/components/fun/FunSkinTones.scss | 21 +- stylesheets/components/fun/FunSticker.scss | 10 + stylesheets/components/fun/FunTabs.scss | 2 +- stylesheets/manifest.scss | 4 +- ts/components/AnimatedEmojiGalore.tsx | 14 +- ts/components/CallReactionBurstEmoji.tsx | 2 +- ts/components/CallScreen.tsx | 25 +- ts/components/CompositionArea.stories.tsx | 5 +- ts/components/CompositionArea.tsx | 110 ++++++--- ts/components/CompositionInput.stories.tsx | 4 +- ts/components/CompositionInput.tsx | 25 +- ts/components/CompositionTextArea.tsx | 15 +- ...omizingPreferredReactionsModal.stories.tsx | 5 +- .../CustomizingPreferredReactionsModal.tsx | 15 +- .../DraftGifMessageSendModal.stories.tsx | 114 +++++++++ ts/components/DraftGifMessageSendModal.tsx | 106 +++++++++ .../ForwardMessagesModal.stories.tsx | 5 +- ts/components/ForwardMessagesModal.tsx | 2 + ts/components/GlobalModalContainer.tsx | 11 + ts/components/MediaEditor.stories.tsx | 3 +- ts/components/MediaEditor.tsx | 10 +- ts/components/Preferences.stories.tsx | 3 + ts/components/Preferences.tsx | 20 ++ ts/components/ProfileEditor.stories.tsx | 13 +- ts/components/ProfileEditor.tsx | 108 ++++++--- ts/components/ProfileEditorModal.tsx | 8 +- ts/components/ReactionPickerPicker.tsx | 22 +- ts/components/StoryCreator.stories.tsx | 5 +- ts/components/StoryCreator.tsx | 15 +- ts/components/StoryViewer.stories.tsx | 5 +- ts/components/StoryViewer.tsx | 13 +- .../StoryViewsNRepliesModal.stories.tsx | 2 +- ts/components/StoryViewsNRepliesModal.tsx | 17 +- ts/components/TextStoryCreator.tsx | 13 +- ts/components/UserText.tsx | 10 +- .../conversation/Emojify.stories.tsx | 12 +- ts/components/conversation/Emojify.tsx | 90 +++---- ts/components/conversation/Message.tsx | 33 ++- ts/components/conversation/MessageBody.tsx | 4 +- .../conversation/MessageTextRenderer.tsx | 30 +-- .../conversation/ReactionPicker.stories.tsx | 13 +- ts/components/conversation/ReactionPicker.tsx | 9 +- ts/components/conversation/ReactionViewer.tsx | 38 ++- .../conversation/TimelineItem.stories.tsx | 7 +- .../conversation/TimelineMessage.stories.tsx | 7 +- .../conversationList/MessageSearchResult.tsx | 2 +- ts/components/emoji/Emoji.stories.tsx | 59 ----- ts/components/emoji/Emoji.tsx | 70 ------ ts/components/emoji/EmojiButton.stories.tsx | 5 +- ts/components/emoji/EmojiButton.tsx | 37 ++- ts/components/emoji/EmojiPicker.stories.tsx | 13 +- ts/components/emoji/EmojiPicker.tsx | 163 ++++++++----- ts/components/emoji/lib.ts | 91 +++----- ts/components/fun/FunEmoji.stories.tsx | 8 +- ts/components/fun/FunEmoji.tsx | 163 +++++++++++-- ts/components/fun/FunEmojiPicker.stories.tsx | 8 +- ts/components/fun/FunEmojiPicker.tsx | 11 +- ts/components/fun/FunGif.stories.tsx | 111 +++++++++ ts/components/fun/FunGif.tsx | 183 +++++++++++++++ ts/components/fun/FunPicker.stories.tsx | 23 +- ts/components/fun/FunPicker.tsx | 12 +- ts/components/fun/FunProvider.tsx | 79 +++++-- ts/components/fun/{base => }/FunSkinTones.tsx | 54 +++-- ts/components/fun/FunSticker.stories.tsx | 36 +++ ts/components/fun/FunSticker.tsx | 26 +++ .../fun/FunStickerPicker.stories.tsx | 62 +++++ ts/components/fun/FunStickerPicker.tsx | 58 +++++ ts/components/fun/base/FunImage.tsx | 63 +++-- ts/components/fun/base/FunItem.tsx | 52 +---- ts/components/fun/base/FunLightbox.tsx | 158 +++++++++++++ ts/components/fun/base/FunSearch.tsx | 23 +- ts/components/fun/base/FunSubNav.tsx | 2 - ts/components/fun/base/FunTabs.tsx | 2 +- .../fun/{FunConstants.tsx => constants.tsx} | 14 -- ts/components/fun/data/emojis.ts | 95 +++++--- ts/components/fun/data/gifs.ts | 10 +- ts/components/fun/data/infinite.ts | 2 +- ts/components/fun/data/segments.ts | 132 +++++++++++ ts/components/fun/data/tenor.ts | 22 +- ts/components/fun/mocks.ts | 27 +++ ts/components/fun/panels/FunPanelEmojis.tsx | 43 ++-- ts/components/fun/panels/FunPanelGifs.tsx | 219 +++++++++++++----- ts/components/fun/panels/FunPanelStickers.tsx | 174 +++++++++----- ts/components/fun/types.tsx | 6 + .../InstallScreenQrCodeNotScannedStep.tsx | 8 +- ts/main/settingsChannel.ts | 2 + .../auto-substitute-ascii-emojis/index.tsx | 15 +- ts/quill/emoji/blot.tsx | 38 +-- ts/quill/emoji/completion.tsx | 92 +++++--- ts/quill/emoji/matchers.ts | 22 +- ts/quill/signal-clipboard/util.ts | 10 +- ts/reactions/preferredReactionEmoji.ts | 8 +- ts/services/globalMessageAudio.ts | 5 +- ts/state/ducks/composer.ts | 4 +- ts/state/ducks/globalModals.ts | 28 +++ ts/state/ducks/items.ts | 9 +- ts/state/ducks/preferredReactions.ts | 15 +- ts/state/selectors/globalModals.ts | 5 + ts/state/selectors/items.ts | 27 ++- ts/state/smart/CompositionArea.tsx | 24 +- ts/state/smart/CompositionTextArea.tsx | 5 +- .../CustomizingPreferredReactionsModal.tsx | 10 +- ts/state/smart/DraftGifMessageSendModal.tsx | 152 ++++++++++++ ts/state/smart/EmojiPicker.tsx | 25 +- ts/state/smart/FunProvider.tsx | 42 ++-- ts/state/smart/GlobalModalContainer.tsx | 8 + ts/state/smart/ProfileEditorModal.tsx | 10 +- ts/state/smart/ReactionPicker.tsx | 8 +- ts/state/smart/StoryCreator.tsx | 10 +- ts/state/smart/StoryViewer.tsx | 10 +- ts/state/smart/renderEmojiPicker.tsx | 4 +- .../reactions/preferredReactionEmoji_test.ts | 25 +- .../state/ducks/preferredReactions_test.ts | 3 +- ts/test-both/state/selectors/items_test.ts | 31 ++- .../quill/emoji/completion_test.tsx | 10 +- ts/test-mock/messaging/reaction_test.ts | 4 +- ts/test-node/components/fun/segments_test.ts | 135 +++++++++++ ts/textsecure/WebAPI.ts | 40 ++-- ts/types/Storage.d.ts | 2 +- ts/types/StorageUIKeys.ts | 2 +- ts/util/createIPCEvents.ts | 12 +- ts/util/isAbortError.ts | 9 + ts/util/lint/exceptions.json | 21 ++ ts/util/loadable.ts | 6 +- ts/windows/preload.ts | 2 + ts/windows/settings/app.tsx | 4 + ts/windows/settings/preload.ts | 14 +- 142 files changed, 3581 insertions(+), 1280 deletions(-) delete mode 100644 stylesheets/_emoji.scss create mode 100644 stylesheets/components/DraftGifMessageSendModal.scss create mode 100644 stylesheets/components/fun/FunGif.scss create mode 100644 stylesheets/components/fun/FunLightbox.scss create mode 100644 stylesheets/components/fun/FunSticker.scss create mode 100644 ts/components/DraftGifMessageSendModal.stories.tsx create mode 100644 ts/components/DraftGifMessageSendModal.tsx delete mode 100644 ts/components/emoji/Emoji.stories.tsx delete mode 100644 ts/components/emoji/Emoji.tsx create mode 100644 ts/components/fun/FunGif.stories.tsx create mode 100644 ts/components/fun/FunGif.tsx rename ts/components/fun/{base => }/FunSkinTones.tsx (51%) create mode 100644 ts/components/fun/FunSticker.stories.tsx create mode 100644 ts/components/fun/FunSticker.tsx create mode 100644 ts/components/fun/FunStickerPicker.stories.tsx create mode 100644 ts/components/fun/FunStickerPicker.tsx create mode 100644 ts/components/fun/base/FunLightbox.tsx rename ts/components/fun/{FunConstants.tsx => constants.tsx} (82%) create mode 100644 ts/components/fun/data/segments.ts create mode 100644 ts/components/fun/types.tsx create mode 100644 ts/state/smart/DraftGifMessageSendModal.tsx create mode 100644 ts/test-node/components/fun/segments_test.ts create mode 100644 ts/util/isAbortError.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 94e44df4b..136412809 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1702,6 +1702,10 @@ "messageformat": "To change the name of this device, open Signal on your phone and navigate to Settings > Linked devices", "description": "Shown in the Settings window below the read-only device name" }, + "icu:Preferences__EmojiSkinToneDefaultSetting__Label": { + "messageformat": "Emoji skin tone", + "description": "Preferences Window > Chats Tab > Emoji skin tone default setting > Label" + }, "icu:chooseDeviceName": { "messageformat": "Choose this device's name", "description": "(Deleted 2025/02/26) The header shown on the 'choose device name' screen in the device linking process" @@ -3171,6 +3175,10 @@ "messageformat": "No stickers found", "description": "FunPicker > Stickers Panel > Search Results > Empty State > Heading" }, + "icu:FunPanelStickers__LightboxDialog__Label": { + "messageformat": "Sticker Preview", + "description": "FunPicker > Stickers Panel > Lightbox Dialog (long press on sticker to see large preview) > Label" + }, "icu:FunPanelGifs__SearchLabel--Tenor": { "messageformat": "Search GIFs via Tenor", "description": "FunPicker > GIFs Panel > Search Input > Label (Must use brand name 'Tenor')" @@ -3235,6 +3243,42 @@ "messageformat": "No GIFs found", "description": "FunPicker > Gifs Panel > Search Results > Empty State > Heading" }, + "icu:FunPanelGifs__LightboxDialog__Label": { + "messageformat": "GIF Preview", + "description": "FunPicker > Gifs Panel > Lightbox Dialog (long press on gif to see large preview) > Label" + }, + "icu:FunSearch__ClearButtonLabel": { + "messageformat": "Clear Search", + "description": "FunPicker > Search Field > Clear Search Button > Accessibility Label" + }, + "icu:FunSkinTones__List": { + "messageformat": "Skin tone", + "description": "FunPicker > Emojis Panel > Skin Tone Picker > Accessibility label" + }, + "icu:FunSkinTones__ListItem--None": { + "messageformat": "None", + "description": "FunPicker > Emojis Panel > Skin Tone Picker > Skin Tone Option: None > Accessibility label" + }, + "icu:FunSkinTones__ListItem--Light": { + "messageformat": "Light skin tone", + "description": "Fun Picker > Emojis Panel > Skin Tone Picker > Skin Tone Option: Light skin tone > Accessibility label" + }, + "icu:FunSkinTones__ListItem--MediumLight": { + "messageformat": "Medium-light skin tone", + "description": "Fun Picker > Emojis Panel > Skin Tone Picker > Skin Tone Option: Medium-light skin tone > Accessibility label" + }, + "icu:FunSkinTones__ListItem--Medium": { + "messageformat": "Medium skin tone", + "description": "Fun Picker > Emojis Panel > Skin Tone Picker > Skin Tone Option: Medium skin tone > Accessibility label" + }, + "icu:FunSkinTones__ListItem--MediumDark": { + "messageformat": "Medium-dark skin tone", + "description": "Fun Picker > Emojis Panel > Skin Tone Picker > Skin Tone Option: Medium-dark skin tone > Accessibility label" + }, + "icu:FunSkinTones__ListItem--Dark": { + "messageformat": "Dark skin tone", + "description": "Fun Picker > Emojis Panel > Skin Tone Picker > Skin Tone Option: Dark skin tone > Accessibility label" + }, "icu:confirmation-dialog--Cancel": { "messageformat": "Cancel", "description": "Appears on the cancel button in confirmation dialogs." @@ -5315,6 +5359,18 @@ "messageformat": "Add an Emoji, Sticker, or GIF", "description": "Composition Area > Fun Button > Accessibility label" }, + "icu:CompositionArea__ConfirmGifSelection__Title": { + "messageformat": "Replace attachment?", + "description": "Composition Area > GIF Picker > After selecting a GIF > When you already have an attachment > Confirm Dialog > Title" + }, + "icu:CompositionArea__ConfirmGifSelection__Body": { + "messageformat": "Adding this GIF will replace the item in your current draft message.", + "description": "Composition Area > GIF Picker > After selecting a GIF > When you already have an attachment > Confirm Dialog > Body" + }, + "icu:CompositionArea__ConfirmGifSelection__ReplaceButton": { + "messageformat": "Replace", + "description": "Composition Area > GIF Picker > After selecting a GIF > When you already have an attachment > Confirm Dialog > Replace Button" + }, "icu:CompositionInput__editing-message": { "messageformat": "Edit message", "description": "Status text displayed above composition input when editing a message" @@ -7833,6 +7889,18 @@ "messageformat": "Edited", "description": "label for an edited message" }, + "icu:DraftGifMessageSendModal__Title": { + "messageformat": "Add a message", + "description": "Draft GIF Message Send Modal > Title" + }, + "icu:DraftGifMessageSendModal__SendButtonLabel": { + "messageformat": "Send", + "description": "Draft GIF Message Send Modal > Send Button > Label" + }, + "icu:DraftGifMessageSendModal__CancelButtonLabel": { + "messageformat": "Cancel", + "description": "Draft GIF Message Send Modal > Cancel Button > Label" + }, "icu:EditHistoryMessagesModal__title": { "messageformat": "Edit history", "description": "Modal title for the edit history messages modal" diff --git a/package.json b/package.json index 2ad180b92..cbce13ed5 100644 --- a/package.json +++ b/package.json @@ -590,9 +590,10 @@ "node_modules/**", "!node_modules/underscore/**", "!node_modules/emoji-datasource/emoji_pretty.json", - "!node_modules/emoji-datasource/**/*.png", - "!node_modules/emoji-datasource-apple/emoji_pretty.json", - "!node_modules/emoji-datasource-apple/img/apple/sheets*", + "!node_modules/emoji-datasource/img/**/*.png", + "node_modules/emoji-datasource/categories.json", + "node_modules/emoji-datasource/emoji.json", + "!node_modules/emoji-datasource-apple/**", "!node_modules/spellchecker/vendor/hunspell/**/*", "!node_modules/@formatjs/intl-displaynames/**/*", "!node_modules/@formatjs/intl-listformat/**/*", diff --git a/stylesheets/_emoji.scss b/stylesheets/_emoji.scss deleted file mode 100644 index adc859b9c..000000000 --- a/stylesheets/_emoji.scss +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2016 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -span.emoji { - display: -moz-inline-box; - -moz-box-orient: vertical; - display: inline-block; - vertical-align: baseline; - *vertical-align: auto; - *zoom: 1; - *display: inline; - width: 1em; - height: 1em; - background-size: 1em; - background-repeat: no-repeat; - text-indent: -9999px; - background-position: 50%, 50%; - background-size: contain; -} - -span.emoji-sizer { - line-height: 0.81em; - font-size: 1em; - margin-block: -2px; - margin-inline: 0; -} - -span.emoji-outer { - display: -moz-inline-box; - display: inline-block; - *display: inline; - height: 1em; - width: 1em; -} - -span.emoji-inner { - display: -moz-inline-box; - display: inline-block; - text-indent: -9999px; - width: 100%; - height: 100%; - vertical-align: baseline; - *vertical-align: auto; - *zoom: 1; -} - -img.emoji { - height: 1.4em; - margin-bottom: -5px; - margin-inline: 2px; - vertical-align: baseline; - width: 1.4em; -} - -img.emoji.small { - width: 32px; - height: 32px; -} -img.emoji.medium { - width: 36px; - height: 36px; -} -img.emoji.large { - width: 40px; - height: 40px; -} -img.emoji.extra-large { - width: 48px; - height: 48px; -} -img.emoji.max { - width: 56px; - height: 56px; -} - -img.emoji--invisible { - visibility: hidden; -} - -// we need these, or we'll make conversation items too big in the left-nav -.conversations img.emoji.small { - width: 1em; - height: 1em; -} -.conversations img.emoji.medium { - width: 1em; - height: 1em; -} -.conversations img.emoji.large { - width: 1em; - height: 1em; -} -.conversations img.emoji.jumbo { - width: 1em; - height: 1em; -} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 786de91a0..2ee9083d9 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2120,14 +2120,6 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', overflow: hidden; text-overflow: ellipsis; } - - img.emoji { - height: 1em; - margin-inline-end: 3px; - margin-bottom: 3px; - vertical-align: middle; - width: 1em; - } } } @@ -7069,52 +7061,6 @@ button.module-calling-participants-list__contact { } } -// Module: Emoji -@mixin emoji-size($size) { - &--#{$size} { - width: $size; - height: $size; - &--inline { - display: inline-block; - vertical-align: bottom; - background-size: $size $size; - } - } - &__image--#{$size} { - width: $size; - height: $size; - /* stylelint-disable-next-line declaration-property-value-disallowed-list */ - transform: translate3d(0, 0, 0); - vertical-align: baseline; - } -} - -.module-emoji { - display: flex; - justify-content: center; - align-items: center; - color: transparent; - font-family: auto; - - @include mixins.light-theme() { - caret-color: variables.$color-gray-90; - } - - @include mixins.dark-theme() { - caret-color: variables.$color-gray-05; - } - - @include emoji-size(16px); - @include emoji-size(18px); - @include emoji-size(20px); - @include emoji-size(24px); - @include emoji-size(28px); - @include emoji-size(32px); - @include emoji-size(48px); - @include emoji-size(64px); - @include emoji-size(66px); -} - // Module: Last Seen Indicator .module-last-seen-indicator { diff --git a/stylesheets/components/CallingReactionsToasts.scss b/stylesheets/components/CallingReactionsToasts.scss index 820ee9c90..e74481b83 100644 --- a/stylesheets/components/CallingReactionsToasts.scss +++ b/stylesheets/components/CallingReactionsToasts.scss @@ -56,7 +56,7 @@ position: relative; } -.CallingReactionsToasts__reaction .module-emoji { +.CallingReactionsToasts__reaction .FunStaticEmoji { position: absolute; // Float the emoji outside of the toast bubble inset-inline-start: -60px; diff --git a/stylesheets/components/CompositionInput.scss b/stylesheets/components/CompositionInput.scss index 284f0cc75..8bb09f3d7 100644 --- a/stylesheets/components/CompositionInput.scss +++ b/stylesheets/components/CompositionInput.scss @@ -24,12 +24,6 @@ inset-inline: 0; font-style: normal; } - - .emoji-blot { - width: 20px; - height: 20px; - vertical-align: text-bottom; - } } } diff --git a/stylesheets/components/DraftGifMessageSendModal.scss b/stylesheets/components/DraftGifMessageSendModal.scss new file mode 100644 index 000000000..4521efe65 --- /dev/null +++ b/stylesheets/components/DraftGifMessageSendModal.scss @@ -0,0 +1,8 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +@use '../variables'; + +.DraftGifMessageSendModal__GifPreview { + display: flex; + justify-content: center; +} diff --git a/stylesheets/components/ReactionPickerPicker.scss b/stylesheets/components/ReactionPickerPicker.scss index 7554237d4..38452e469 100644 --- a/stylesheets/components/ReactionPickerPicker.scss +++ b/stylesheets/components/ReactionPickerPicker.scss @@ -66,7 +66,7 @@ transition: background 200ms variables.$ease-out-expo; } - .module-emoji { + .FunStaticEmoji { transform: scale( math.div($button-content-size, $emoji-size-from-component) ); @@ -160,7 +160,7 @@ &--emoji { @mixin focus-or-hover-styles { - .module-emoji { + .FunStaticEmoji { transform: scale( math.div($big-emoji-size, $emoji-size-from-component) ) @@ -205,7 +205,7 @@ &--selected { opacity: 1; - .module-emoji { + .FunStaticEmoji { transform: scale( math.div($big-emoji-size, $emoji-size-from-component) ); diff --git a/stylesheets/components/fun/Fun.scss b/stylesheets/components/fun/Fun.scss index 43aa6fb03..771c08eb2 100644 --- a/stylesheets/components/fun/Fun.scss +++ b/stylesheets/components/fun/Fun.scss @@ -3,15 +3,18 @@ @use './FunConstants.scss'; @use './FunEmoji.scss'; +@use './FunGif.scss'; @use './FunGrid.scss'; @use './FunImage.scss'; @use './FunItem.scss'; +@use './FunLightbox.scss'; @use './FunPanel.scss'; @use './FunResults.scss'; @use './FunPopover.scss'; @use './FunScroller.scss'; @use './FunSearch.scss'; @use './FunSkinTones.scss'; +@use './FunSticker.scss'; @use './FunSubNav.scss'; @use './FunTabs.scss'; @use './FunWaterfall.scss'; diff --git a/stylesheets/components/fun/FunEmoji.scss b/stylesheets/components/fun/FunEmoji.scss index d6dfcf52f..78c7563fc 100644 --- a/stylesheets/components/fun/FunEmoji.scss +++ b/stylesheets/components/fun/FunEmoji.scss @@ -1,27 +1,206 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -.FunEmoji { - display: inline-block; - flex: none; - contain: strict; - vertical-align: top; +// There are 62 rows and columns in the generated sprite sheet. +$emoji-sprite-sheet-grid-item-count: 62; + +@mixin emoji-sprite($sheet, $margin, $scale) { + $size: calc($sheet * 1px * $scale); + $margin-start: calc($margin * $scale); + $margin-end: calc($margin * $scale); + $size-outer: calc($size + $margin-start + $margin-end); + $image: url('../images/emoji-sheet-#{$sheet}.webp'); + background-image: $image; + background-size: calc($size-outer * $emoji-sprite-sheet-grid-item-count); + background-position-x: calc( + var(--fun-emoji-sheet-x) * ($size-outer * -1) + ($margin-start * -1) + ); + background-position-y: calc( + var(--fun-emoji-sheet-y) * ($size-outer * -1) + ($margin-start * -1) + ); + background-repeat: no-repeat; } -.FunEmoji--Size16 { +@mixin hidpi { + @media (resolution > 1x) { + @content; + } +} + +@mixin emoji-jumbo { + background-image: var(--fun-emoji-jumbo-image); + background-size: contain; + background-position: center; + background-repeat: no-repeat; +} + +.FunStaticEmoji { + contain: strict; + display: inline-block; + position: relative; + z-index: 0; + flex: none; + content-visibility: auto; +} + +.FunStaticEmoji--Blot { + display: inline-block; + margin-bottom: round(-0.4em + 1px, 1px); + vertical-align: baseline; +} + +.FunStaticEmoji--Size16 { width: 16px; height: 16px; - background-image: url('../images/emoji-sheet-32.webp'); - background-size: 1054px; - background-position-x: calc(var(--fun-emoji-sheet-x) * -17px - 0.5px); - background-position-y: calc(var(--fun-emoji-sheet-y) * -17px - 0.5px); + // Use 32px variant even on smaller sizes to avoid shipping the 16px sheet + @include emoji-sprite($sheet: 32, $margin: 1px, $scale: calc(16 / 32)); } -.FunEmoji--Size32 { +.FunStaticEmoji--Size18 { + width: 18px; + height: 18px; + @include emoji-sprite($sheet: 32, $margin: 1px, $scale: calc(18 / 32)); + @include hidpi { + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: calc(18 / 64)); + } +} + +.FunStaticEmoji--Size20 { + width: 20px; + height: 20px; + @include emoji-sprite($sheet: 32, $margin: 1px, $scale: calc(20 / 32)); + @include hidpi { + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: calc(20 / 64)); + } +} + +.FunStaticEmoji--Size24 { + width: 24px; + height: 24px; + @include emoji-sprite($sheet: 32, $margin: 1px, $scale: calc(24 / 32)); + @include hidpi { + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: calc(24 / 64)); + } +} + +.FunStaticEmoji--Size28 { + width: 28px; + height: 28px; + @include emoji-sprite($sheet: 32, $margin: 1px, $scale: calc(28 / 32)); + @include hidpi { + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: calc(28 / 64)); + } +} + +.FunStaticEmoji--Size32 { width: 32px; height: 32px; - background-image: url('../images/emoji-sheet-64.webp'); - background-size: 2046px; - background-position-x: calc(var(--fun-emoji-sheet-x) * -33px - 0.5px); - background-position-y: calc(var(--fun-emoji-sheet-y) * -33px - 0.5px); + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: calc(32 / 64)); +} + +.FunStaticEmoji--Size36 { + width: 36px; + height: 36px; + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: calc(36 / 64)); + @include hidpi { + @include emoji-jumbo; + } +} + +.FunStaticEmoji--Size40 { + width: 40px; + height: 40px; + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: calc(40 / 64)); + @include hidpi { + @include emoji-jumbo; + } +} + +.FunStaticEmoji--Size48 { + width: 48px; + height: 48px; + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: calc(48 / 64)); + @include hidpi { + @include emoji-jumbo; + } +} + +.FunStaticEmoji--Size56 { + width: 56px; + height: 56px; + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: calc(56 / 64)); + @include hidpi { + @include emoji-jumbo; + } +} + +.FunStaticEmoji--Size64 { + width: 64px; + height: 64px; + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: calc(64 / 64)); + @include hidpi { + @include emoji-jumbo; + } +} + +.FunStaticEmoji--Size66 { + width: 66px; + height: 66px; + @include emoji-jumbo; +} + +$inline-emoji-container-name: inline-emoji; + +.FunInlineEmoji { + position: relative; + z-index: 0; + contain: strict; + container: $inline-emoji-container-name / inline-size; + display: inline-block; + flex: none; + width: var(--fun-inline-emoji-size, round(1.4em, 1px)); + height: var(--fun-inline-emoji-size, round(1.4em, 1px)); + margin-bottom: round(-0.4em + 1px, 1px); + vertical-align: baseline; + content-visibility: auto; + user-select: none; +} + +.FunEmojiSelectionText { + display: block; + position: absolute; + inset: 0; + color: transparent; + font-size: 64px; + line-height: 64px; + user-select: text; +} + +.FunInlineEmoji__Image { + position: relative; + display: inline-block; + width: 64px; + height: 64px; + z-index: 1; + user-select: none; + + // Use 32px variant even on smaller sizes because it looks better on lowdpi displays + @include emoji-sprite($sheet: 32, $margin: 1px, $scale: 2); + + @container #{$inline-emoji-container-name} (width > 16px) { + @include hidpi { + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: 1); + } + } + + @container #{$inline-emoji-container-name} (width >= 32px) { + @include emoji-sprite($sheet: 64, $margin: 1px, $scale: 1); + @include hidpi { + @include emoji-jumbo; + } + } + + @container #{$inline-emoji-container-name} (width > 64px) { + @include emoji-jumbo; + } } diff --git a/stylesheets/components/fun/FunGif.scss b/stylesheets/components/fun/FunGif.scss new file mode 100644 index 000000000..2f9cd0e47 --- /dev/null +++ b/stylesheets/components/fun/FunGif.scss @@ -0,0 +1,62 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use '../../variables'; +@use '../../mixins'; + +.FunGif { + width: auto; + height: auto; + max-width: 100%; + vertical-align: top; + border-radius: 8px; +} + +.FunGifPreview { + position: relative; + z-index: 0; + width: fit-content; + height: fit-content; + contain: layout; +} + +.FunGifPreview__Sizer { + z-index: 0; + width: auto; + height: auto; + max-width: 100%; + max-height: var(--fun-gif-preview-sizer-max-height); +} + +.FunGifPreview__Backdrop { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + background: light-dark(variables.$color-gray-05, variables.$color-gray-75); + border-radius: 8px; +} + +.FunGifPreview__Spinner { + color: light-dark(variables.$color-gray-25, variables.$color-gray-45); +} + +.FunGifPreview__ErrorIcon { + width: 36px; + height: 36px; + @include mixins.color-svg( + '../images/icons/v3/error/error-triangle.svg', + light-dark(variables.$color-gray-25, variables.$color-gray-45) + ); +} + +.FunGifPreview__Video { + position: absolute; + z-index: 1; + inset: 0; + width: 100%; + height: 100%; + border-radius: 8px; +} diff --git a/stylesheets/components/fun/FunGrid.scss b/stylesheets/components/fun/FunGrid.scss index 5eae65926..d64107ef5 100644 --- a/stylesheets/components/fun/FunGrid.scss +++ b/stylesheets/components/fun/FunGrid.scss @@ -43,7 +43,7 @@ .FunGrid__HeaderText { padding-block: 4px; flex: 1; - @include mixins.font-body-1; + @include mixins.font-body-2; font-weight: 600; color: light-dark( variables.$color-black-alpha-50, diff --git a/stylesheets/components/fun/FunItem.scss b/stylesheets/components/fun/FunItem.scss index a5f2607bc..48c95e974 100644 --- a/stylesheets/components/fun/FunItem.scss +++ b/stylesheets/components/fun/FunItem.scss @@ -16,6 +16,7 @@ width: 100%; height: 100%; vertical-align: top; + padding: 2px; border-radius: 10px; border: 1px solid FunConstants.$Fun__BgColor; } @@ -33,17 +34,3 @@ } } } - -.FunItem__Sticker { - width: 68px; - height: 68px; - border-radius: 4px; - vertical-align: top; -} - -.FunItem__Gif { - width: 160px; - height: auto; - vertical-align: top; - border-radius: 8px; -} diff --git a/stylesheets/components/fun/FunLightbox.scss b/stylesheets/components/fun/FunLightbox.scss new file mode 100644 index 000000000..83ef74223 --- /dev/null +++ b/stylesheets/components/fun/FunLightbox.scss @@ -0,0 +1,33 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use '../../variables'; + +.FunLightbox__Backdrop { + position: absolute; + z-index: 100001; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: variables.$color-black-alpha-60; + pointer-events: none; + transition: all ease-out 400ms; +} + +.FunLightbox__Dialog { + pointer-events: none; + filter: drop-shadow(0 4px 6px variables.$color-black-alpha-20); + transition: all ease-out 400ms; +} + +@starting-style { + .FunLightbox__Backdrop { + opacity: 0; + } + + .FunLightbox__Dialog { + opacity: 0; + transform: scale(95%); + } +} diff --git a/stylesheets/components/fun/FunSearch.scss b/stylesheets/components/fun/FunSearch.scss index a0c1706d5..0403b78dd 100644 --- a/stylesheets/components/fun/FunSearch.scss +++ b/stylesheets/components/fun/FunSearch.scss @@ -7,6 +7,9 @@ $icon-image-size: 20px; $icon-actual-size: 18px; $icon-margin-inline-start: 12px; +$clear-padding-inline: 6px; +$clear-button-size: 20px; +$clear-icon-size: 16px; $input-padding-inline: 12px; .FunSearch__Container { @@ -56,12 +59,50 @@ $input-padding-inline: 12px; } } -.FunSearch__NoResults { +.FunSearch__Clear { + @include mixins.button-reset(); + & { + position: absolute; + display: flex; + align-items: center; + inset-block: 0; + inset-inline-end: 0; + padding-inline: $clear-padding-inline; + } + &:focus { + // Handled by .FunSearch__ClearButton + outline: none; + } +} + +.FunSearch__ClearButton { display: flex; - width: 100cqw; - height: 100cqh; align-items: center; justify-content: center; - @include mixins.font-body-1; - color: light-dark(variables.$color-gray-90, variables.$color-gray-05); + width: $clear-button-size; + height: $clear-button-size; + border-radius: 9999px; + color: light-dark( + variables.$color-black-alpha-50, + variables.$color-white-alpha-50 + ); + + .FunSearch__Clear:hover &, + .FunSearch__Clear:focus & { + color: light-dark(variables.$color-gray-75, variables.$color-gray-25); + } + + .FunSearch__Clear:focus & { + @include mixins.keyboard-mode { + outline: 2px solid variables.$color-ultramarine; + } + } + + &::before { + content: ''; + display: block; + width: $clear-icon-size; + height: $clear-icon-size; + @include mixins.color-svg('../images/icons/v3/x/x.svg', currentColor); + } } diff --git a/stylesheets/components/fun/FunSkinTones.scss b/stylesheets/components/fun/FunSkinTones.scss index 44468a194..ced3f06ca 100644 --- a/stylesheets/components/fun/FunSkinTones.scss +++ b/stylesheets/components/fun/FunSkinTones.scss @@ -11,20 +11,31 @@ } .FunSkinTones__ListBoxItem { + padding: 1px; + + &:focus { + // Handled in .FunSkinTones__ListBoxItem + outline: none; + } +} + +.FunSkinTones__ListBoxItemButton { padding: 4px; border-radius: 10px; - border: 1px solid FunConstants.$Fun__BgColor; - &:hover, - &:focus { + .FunSkinTones__ListBoxItem:hover &, + .FunSkinTones__ListBoxItem:focus & { background: light-dark(variables.$color-gray-02, variables.$color-gray-78); } - &:focus { - outline: none; + .FunSkinTones__ListBoxItem:focus & { @include mixins.keyboard-mode { outline: 2px solid variables.$color-ultramarine; outline-offset: -2px; } } + + .FunSkinTones__ListBoxItem[data-selected='true'] & { + background: light-dark(variables.$color-gray-05, variables.$color-gray-60); + } } diff --git a/stylesheets/components/fun/FunSticker.scss b/stylesheets/components/fun/FunSticker.scss new file mode 100644 index 000000000..ab43b3b3d --- /dev/null +++ b/stylesheets/components/fun/FunSticker.scss @@ -0,0 +1,10 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.FunSticker { + width: auto; + height: auto; + max-width: 100%; + border-radius: 4px; + vertical-align: top; +} diff --git a/stylesheets/components/fun/FunTabs.scss b/stylesheets/components/fun/FunTabs.scss index 86eb27f51..e1c3f64cb 100644 --- a/stylesheets/components/fun/FunTabs.scss +++ b/stylesheets/components/fun/FunTabs.scss @@ -44,7 +44,7 @@ .FunTabs__TabButton { // Note: This must not have z-index for the animation position: relative; - padding-block: 2px; + padding-block: 5px; padding-inline: 12px; border-radius: 9999px; text-align: center; diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index a0278d5a5..160a47d53 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -8,9 +8,6 @@ @use 'global'; @use 'titlebar'; -// Old style: components -@use 'emoji'; - // Old style: modules @use 'modules'; @@ -97,6 +94,7 @@ @use 'components/DeleteMessagesModal.scss'; @use 'components/DisappearingTimeDialog.scss'; @use 'components/DisappearingTimerSelect.scss'; +@use 'components/DraftGifMessageSendModal.scss'; @use 'components/EditConversationAttributesModal.scss'; @use 'components/EditHistoryMessagesModal.scss'; @use 'components/EditNicknameAndNoteModal.scss'; diff --git a/ts/components/AnimatedEmojiGalore.tsx b/ts/components/AnimatedEmojiGalore.tsx index dbb4e49fd..0d8018cc9 100644 --- a/ts/components/AnimatedEmojiGalore.tsx +++ b/ts/components/AnimatedEmojiGalore.tsx @@ -4,8 +4,14 @@ import React from 'react'; import { animated, to as interpolate, useSprings } from '@react-spring/web'; import { random } from 'lodash'; -import { Emojify } from './conversation/Emojify'; import { useReducedMotion } from '../hooks/useReducedMotion'; +import { FunStaticEmoji } from './fun/FunEmoji'; +import { strictAssert } from '../util/assert'; +import { + getEmojiVariantByKey, + getEmojiVariantKeyByValue, + isEmojiVariantValue, +} from './fun/data/emojis'; export type PropsType = { emoji: string; @@ -39,6 +45,10 @@ export function AnimatedEmojiGalore({ emoji, onAnimationEnd, }: PropsType): JSX.Element { + strictAssert(isEmojiVariantValue(emoji), 'Must be valid english short name'); + const emojiVariantKey = getEmojiVariantKeyByValue(emoji); + const emojiVariant = getEmojiVariantByKey(emojiVariantKey); + const reducedMotion = useReducedMotion(); const [springs] = useSprings(NUM_EMOJIS, i => ({ ...to(i, onAnimationEnd), @@ -67,7 +77,7 @@ export function AnimatedEmojiGalore({ ), }} > - + ))} > diff --git a/ts/components/CallReactionBurstEmoji.tsx b/ts/components/CallReactionBurstEmoji.tsx index 6233678d8..e64bfe894 100644 --- a/ts/components/CallReactionBurstEmoji.tsx +++ b/ts/components/CallReactionBurstEmoji.tsx @@ -164,7 +164,7 @@ export function AnimatedEmoji({ y, }} > - + ); } diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 5b8cf2406..7091e3ffe 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -75,7 +75,6 @@ import { handleOutsideClick } from '../util/handleOutsideClick'; import { Spinner } from './Spinner'; import type { Props as ReactionPickerProps } from './conversation/ReactionPicker'; import type { SmartReactionPicker } from '../state/smart/ReactionPicker'; -import { Emoji } from './emoji/Emoji'; import { CallingRaisedHandsList, CallingRaisedHandsListButton, @@ -86,10 +85,18 @@ import { useCallReactionBursts, } from './CallReactionBurst'; import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; -import { assertDev } from '../util/assert'; +import { assertDev, strictAssert } from '../util/assert'; import { emojiToData } from './emoji/lib'; import { CallingPendingParticipants } from './CallingPendingParticipants'; import type { CallingImageDataCache } from './CallManager'; +import { FunStaticEmoji } from './fun/FunEmoji'; +import { + getEmojiParentByKey, + getEmojiParentKeyByVariantKey, + getEmojiVariantByKey, + getEmojiVariantKeyByValue, + isEmojiVariantValue, +} from './fun/data/emojis'; export type PropsType = { activeCall: ActiveCallType; @@ -1242,13 +1249,25 @@ function useReactionsToast(props: UseReactionsToastType): void { reactions.forEach(({ timestamp, demuxId, value }) => { const conversation = conversationsByDemuxId.get(demuxId); const key = `reactions-${timestamp}-${demuxId}`; + + strictAssert(isEmojiVariantValue(value), 'Expected a valid emoji value'); + const emojiVariantKey = getEmojiVariantKeyByValue(value); + const emojiVariant = getEmojiVariantByKey(emojiVariantKey); + const emojiParentKey = getEmojiParentKeyByVariantKey(emojiVariantKey); + const emojiParent = getEmojiParentByKey(emojiParentKey); + showToast({ key, onlyShowOnce: true, autoClose: true, content: ( - + {demuxId === localDemuxId || (ourServiceId && conversation?.serviceId === ourServiceId) ? i18n('icu:CallingReactions--me') diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index a81e15a87..ca7d2a00e 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -15,6 +15,7 @@ import { RecordingState } from '../types/AudioRecorder'; import { ConversationColors } from '../types/Colors'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { PaymentEventKind } from '../types/Payment'; +import { EmojiSkinTone } from './fun/data/emojis'; const { i18n } = window.SignalContext; @@ -84,9 +85,9 @@ export default { sortedGroupMembers: [], // EmojiButton onPickEmoji: action('onPickEmoji'), - onSetSkinTone: action('onSetSkinTone'), + onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'), recentEmojis: [], - skinTone: 1, + emojiSkinToneDefault: EmojiSkinTone.Type1, // StickerButton knownPacks: [], receivedPacks: [], diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 810d3c37e..6839609ad 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -84,10 +84,9 @@ import * as RemoteConfig from '../RemoteConfig'; import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis'; import type { FunStickerSelection } from './fun/panels/FunPanelStickers'; import type { FunGifSelection } from './fun/panels/FunPanelGifs'; -import { tenorDownload } from './fun/data/tenor'; -import { SignalService as Proto } from '../protobuf'; -import { SKIN_TONE_TO_NUMBER } from './fun/data/emojis'; +import type { SmartDraftGifMessageSendModalProps } from '../state/smart/DraftGifMessageSendModal'; import { strictAssert } from '../util/assert'; +import { ConfirmationDialog } from './ConfirmationDialog'; export type OwnProps = Readonly<{ acceptedMessageRequest: boolean | null; @@ -205,6 +204,9 @@ export type OwnProps = Readonly<{ payload: ForwardMessagesPayload, onForward: () => void ) => void; + toggleDraftGifMessageSendModal: ( + props: SmartDraftGifMessageSendModalProps | null + ) => void; }>; export type Props = Pick< @@ -221,7 +223,10 @@ export type Props = Pick< > & Pick< EmojiButtonProps, - 'onPickEmoji' | 'onSetSkinTone' | 'recentEmojis' | 'skinTone' + | 'onPickEmoji' + | 'onEmojiSkinToneDefaultChange' + | 'recentEmojis' + | 'emojiSkinToneDefault' > & Pick< StickerButtonProps, @@ -304,9 +309,9 @@ export const CompositionArea = memo(function CompositionArea({ sortedGroupMembers, // EmojiButton onPickEmoji, - onSetSkinTone, + onEmojiSkinToneDefaultChange, recentEmojis, - skinTone, + emojiSkinToneDefault, // StickerButton knownPacks, receivedPacks, @@ -358,6 +363,8 @@ export const CompositionArea = memo(function CompositionArea({ selectedMessageIds, toggleSelectMode, toggleForwardMessagesModal, + // DraftGifMessageSendModal + toggleDraftGifMessageSendModal, }: Props): JSX.Element | null { const [dirty, setDirty] = useState(false); const [large, setLarge] = useState(false); @@ -603,14 +610,9 @@ export const CompositionArea = memo(function CompositionArea({ const handleFunPickerSelectEmoji = useCallback( (emojiSelection: FunEmojiSelection) => { - const skinToneNumber = SKIN_TONE_TO_NUMBER.get(emojiSelection.skinTone); - strictAssert( - skinToneNumber, - `Unexpected skin tone: ${emojiSelection.skinTone}` - ); insertEmoji({ shortName: emojiSelection.englishShortName, - skinTone: skinToneNumber, + skinTone: emojiSelection.skinTone, }); }, [insertEmoji] @@ -625,24 +627,53 @@ export const CompositionArea = memo(function CompositionArea({ [sendStickerMessage, conversationId] ); + const [confirmGifSelection, setConfirmGifSelection] = + useState(null); + const handleFunPickerSelectGif = useCallback( async (gifSelection: FunGifSelection) => { - const { url } = gifSelection.attachmentMedia; - - const bytes = await tenorDownload(url); - const file = new File([bytes], 'gif.mp4', { - type: 'video/mp4', - }); - - processAttachments({ - conversationId, - files: [file], - flags: Proto.AttachmentPointer.Flags.GIF, - }); + if (draftAttachments.length > 0) { + setConfirmGifSelection(gifSelection); + } else { + toggleDraftGifMessageSendModal({ + conversationId, + previousComposerDraftText: draftText ?? '', + previousComposerDraftBodyRanges: draftBodyRanges ?? [], + gifSelection, + }); + } }, - [processAttachments, conversationId] + [ + conversationId, + toggleDraftGifMessageSendModal, + draftText, + draftBodyRanges, + draftAttachments, + ] ); + const handleConfirmGifSelection = useCallback(() => { + strictAssert(confirmGifSelection != null, 'Need selected gif to confirm'); + onClearAttachments(conversationId); + toggleDraftGifMessageSendModal({ + conversationId, + previousComposerDraftText: draftText ?? '', + previousComposerDraftBodyRanges: draftBodyRanges ?? [], + gifSelection: confirmGifSelection, + }); + }, [ + confirmGifSelection, + conversationId, + toggleDraftGifMessageSendModal, + draftText, + draftBodyRanges, + onClearAttachments, + ]); + + const handleCancelGifSelection = useCallback(() => { + setConfirmGifSelection(null); + }, []); + const handleFunPickerAddStickerPack = useCallback(() => { pushPanelForConversation({ type: PanelType.StickerManager, @@ -651,6 +682,27 @@ export const CompositionArea = memo(function CompositionArea({ const leftHandSideButtonsFragment = ( <> + {confirmGifSelection && ( + + {i18n('icu:CompositionArea__ConfirmGifSelection__Body')} + + )} {isFunPickerEnabled && ( setComposerFocus(conversationId)} recentEmojis={recentEmojis} - skinTone={skinTone} - onSetSkinTone={onSetSkinTone} + emojiSkinToneDefault={emojiSkinToneDefault} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} /> )} @@ -1057,7 +1109,7 @@ export const CompositionArea = memo(function CompositionArea({ ourConversationId={ourConversationId} platform={platform} recentStickers={recentStickers} - skinTone={skinTone} + emojiSkinToneDefault={emojiSkinToneDefault} sortedGroupMembers={sortedGroupMembers} /> )} @@ -1155,7 +1207,7 @@ export const CompositionArea = memo(function CompositionArea({ quotedMessageId={quotedMessageId} sendCounter={sendCounter} shouldHidePopovers={shouldHidePopovers} - skinTone={skinTone ?? null} + emojiSkinToneDefault={emojiSkinToneDefault ?? null} sortedGroupMembers={sortedGroupMembers} theme={theme} /> diff --git a/ts/components/CompositionInput.stories.tsx b/ts/components/CompositionInput.stories.tsx index fff8d0f4f..047df7bca 100644 --- a/ts/components/CompositionInput.stories.tsx +++ b/ts/components/CompositionInput.stories.tsx @@ -11,6 +11,7 @@ import type { Props } from './CompositionInput'; import { CompositionInput } from './CompositionInput'; import { generateAci } from '../types/ServiceId'; import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext'; +import { EmojiSkinTone } from './fun/data/emojis'; const { i18n } = window.SignalContext; @@ -46,7 +47,8 @@ const useProps = (overrideProps: Partial = {}): Props => { quotedMessageId: null, sendCounter: 0, sortedGroupMembers: overrideProps.sortedGroupMembers ?? [], - skinTone: overrideProps.skinTone ?? null, + emojiSkinToneDefault: + overrideProps.emojiSkinToneDefault ?? EmojiSkinTone.None, theme: React.useContext(StorybookThemeContext), inputApi: null, shouldHidePopovers: null, diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index d860b61ef..289ae0706 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -73,9 +73,12 @@ import { matchStrikethrough, } from '../quill/formatting/matchers'; import { missingCaseError } from '../util/missingCaseError'; +import type { AutoSubstituteAsciiEmojisOptions } from '../quill/auto-substitute-ascii-emojis'; import { AutoSubstituteAsciiEmojis } from '../quill/auto-substitute-ascii-emojis'; import { dropNull } from '../util/dropNull'; import { SimpleQuillWrapper } from './SimpleQuillWrapper'; +import type { EmojiSkinTone } from './fun/data/emojis'; +import { FUN_STATIC_EMOJI_CLASS } from './fun/FunEmoji'; Quill.register( { @@ -117,7 +120,7 @@ export type Props = Readonly<{ isFormattingEnabled: boolean; isActive: boolean; sendCounter: number; - skinTone: NonNullable | null; + emojiSkinToneDefault: EmojiSkinTone; draftText: string | null; draftBodyRanges: HydratedBodyRangesType | null; moduleClassName?: string; @@ -182,7 +185,7 @@ export function CompositionInput(props: Props): React.ReactElement { platform, quotedMessageId, shouldHidePopovers, - skinTone, + emojiSkinToneDefault, sendCounter, sortedGroupMembers, theme, @@ -600,7 +603,9 @@ export function CompositionInput(props: Props): React.ReactElement { // typing new text. This code removes the style tags that we don't want there, and // quill doesn't know about. It can result formatting on the resultant message that // doesn't match the composer. - const withStyles = quill.container.querySelectorAll('[style]'); + const withStyles = quill.container.querySelectorAll( + `[style]:not(.${FUN_STATIC_EMOJI_CLASS})` + ); for (const node of withStyles) { node.attributes.removeNamedItem('style'); } @@ -689,12 +694,12 @@ export function CompositionInput(props: Props): React.ReactElement { React.useEffect(() => { const emojiCompletion = emojiCompletionRef.current; - if (emojiCompletion == null || skinTone == null) { + if (emojiCompletion == null || emojiSkinToneDefault == null) { return; } - emojiCompletion.options.skinTone = skinTone; - }, [skinTone]); + emojiCompletion.options.emojiSkinToneDefault = emojiSkinToneDefault; + }, [emojiSkinToneDefault]); React.useEffect( () => () => { @@ -790,7 +795,9 @@ export function CompositionInput(props: Props): React.ReactElement { ['br', matchBreak], [Node.ELEMENT_NODE, matchNewline], ['IMG', matchEmojiImage], + ['SPAN', matchEmojiImage], ['IMG', matchEmojiBlot], + ['SPAN', matchEmojiBlot], ['STRONG', matchBold], ['EM', matchItalic], ['SPAN', matchMonospace], @@ -831,12 +838,12 @@ export function CompositionInput(props: Props): React.ReactElement { setEmojiPickerElement: setEmojiCompletionElement, onPickEmoji: (emoji: EmojiPickDataType) => callbacksRef.current.onPickEmoji(emoji), - skinTone, + emojiSkinToneDefault, search, }, autoSubstituteAsciiEmojis: { - skinTone, - }, + emojiSkinToneDefault, + } satisfies AutoSubstituteAsciiEmojisOptions, formattingMenu: { i18n, isMenuEnabled: isFormattingEnabled, diff --git a/ts/components/CompositionTextArea.tsx b/ts/components/CompositionTextArea.tsx index fd00db739..7e45fb764 100644 --- a/ts/components/CompositionTextArea.tsx +++ b/ts/components/CompositionTextArea.tsx @@ -15,6 +15,7 @@ import type { ThemeType } from '../types/Util'; import type { Props as EmojiButtonProps } from './emoji/EmojiButton'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import * as grapheme from '../util/grapheme'; +import type { EmojiSkinTone } from './fun/data/emojis'; export type CompositionTextAreaProps = { bodyRanges: HydratedBodyRangesType | null; @@ -31,7 +32,7 @@ export type CompositionTextAreaProps = { draftBodyRanges: HydratedBodyRangesType, caretLocation?: number | undefined ) => void; - onSetSkinTone: (tone: number) => void; + onEmojiSkinToneDefaultChange: (emojiSkinToneDefault: EmojiSkinTone) => void; onSubmit: ( message: string, draftBodyRanges: DraftBodyRanges, @@ -43,7 +44,7 @@ export type CompositionTextAreaProps = { getPreferredBadge: PreferredBadgeSelectorType; draftText: string; theme: ThemeType; -} & Pick; +} & Pick; /** * Essentially an HTML textarea but with support for emoji picker and @@ -63,14 +64,14 @@ export function CompositionTextArea({ onChange, onPickEmoji, onScroll, - onSetSkinTone, + onEmojiSkinToneDefaultChange, onSubmit, onTextTooLong, ourConversationId, placeholder, platform, recentEmojis, - skinTone, + emojiSkinToneDefault, theme, whenToShowRemainingCount = Infinity, }: CompositionTextAreaProps): JSX.Element { @@ -153,7 +154,7 @@ export function CompositionTextArea({ quotedMessageId={null} sendCounter={0} theme={theme} - skinTone={skinTone ?? null} + emojiSkinToneDefault={emojiSkinToneDefault} // These do not apply in the forward modal because there isn't // strictly one conversation conversationId={null} @@ -170,9 +171,9 @@ export function CompositionTextArea({ i18n={i18n} onClose={focusTextEditInput} onPickEmoji={insertEmoji} - onSetSkinTone={onSetSkinTone} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} recentEmojis={recentEmojis} - skinTone={skinTone} + emojiSkinToneDefault={emojiSkinToneDefault} /> {maxLength !== undefined && diff --git a/ts/components/CustomizingPreferredReactionsModal.stories.tsx b/ts/components/CustomizingPreferredReactionsModal.stories.tsx index b95faa41e..16dd74c5f 100644 --- a/ts/components/CustomizingPreferredReactionsModal.stories.tsx +++ b/ts/components/CustomizingPreferredReactionsModal.stories.tsx @@ -9,6 +9,7 @@ import type { Meta } from '@storybook/react'; import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants'; import type { PropsType } from './CustomizingPreferredReactionsModal'; import { CustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal'; +import { EmojiSkinTone } from './fun/data/emojis'; const { i18n } = window.SignalContext; @@ -26,7 +27,7 @@ const defaultProps: ComponentProps = hadSaveError: false, i18n, isSaving: false, - onSetSkinTone: action('onSetSkinTone'), + onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'), originalPreferredReactions: DEFAULT_PREFERRED_REACTION_EMOJI, recentEmojis: ['cake'], replaceSelectedDraftEmoji: action('replaceSelectedDraftEmoji'), @@ -34,7 +35,7 @@ const defaultProps: ComponentProps = savePreferredReactions: action('savePreferredReactions'), selectDraftEmojiToBeReplaced: action('selectDraftEmojiToBeReplaced'), selectedDraftEmojiIndex: undefined, - skinTone: 4, + emojiSkinToneDefault: EmojiSkinTone.Type4, }; export function Default(): JSX.Element { diff --git a/ts/components/CustomizingPreferredReactionsModal.tsx b/ts/components/CustomizingPreferredReactionsModal.tsx index 018584375..adcf1388a 100644 --- a/ts/components/CustomizingPreferredReactionsModal.tsx +++ b/ts/components/CustomizingPreferredReactionsModal.tsx @@ -18,6 +18,7 @@ import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from '../reactions/const import { convertShortName } from './emoji/lib'; import { offsetDistanceModifier } from '../util/popperUtil'; import { handleOutsideClick } from '../util/handleOutsideClick'; +import type { EmojiSkinTone } from './fun/data/emojis'; export type PropsType = { draftPreferredReactions: ReadonlyArray; @@ -27,11 +28,11 @@ export type PropsType = { originalPreferredReactions: ReadonlyArray; recentEmojis: ReadonlyArray; selectedDraftEmojiIndex: undefined | number; - skinTone: number; + emojiSkinToneDefault: EmojiSkinTone; cancelCustomizePreferredReactionsModal(): unknown; deselectDraftEmoji(): unknown; - onSetSkinTone(tone: number): unknown; + onEmojiSkinToneDefaultChange: (emojiSkinToneDefault: EmojiSkinTone) => void; replaceSelectedDraftEmoji(newEmoji: string): unknown; resetDraftEmoji(): unknown; savePreferredReactions(): unknown; @@ -42,10 +43,11 @@ export function CustomizingPreferredReactionsModal({ cancelCustomizePreferredReactionsModal, deselectDraftEmoji, draftPreferredReactions, + emojiSkinToneDefault, hadSaveError, i18n, isSaving, - onSetSkinTone, + onEmojiSkinToneDefaultChange, originalPreferredReactions, recentEmojis, replaceSelectedDraftEmoji, @@ -53,7 +55,6 @@ export function CustomizingPreferredReactionsModal({ savePreferredReactions, selectDraftEmojiToBeReplaced, selectedDraftEmojiIndex, - skinTone, }: Readonly): JSX.Element { const [referenceElement, setReferenceElement] = useState(null); @@ -98,7 +99,7 @@ export function CustomizingPreferredReactionsModal({ !isSaving && !isEqual( DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES.map(shortName => - convertShortName(shortName, skinTone) + convertShortName(shortName, emojiSkinToneDefault) ), draftPreferredReactions ); @@ -188,8 +189,8 @@ export function CustomizingPreferredReactionsModal({ replaceSelectedDraftEmoji(emoji); }} recentEmojis={recentEmojis} - skinTone={skinTone} - onSetSkinTone={onSetSkinTone} + emojiSkinToneDefault={emojiSkinToneDefault} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} onClose={() => { deselectDraftEmoji(); }} diff --git a/ts/components/DraftGifMessageSendModal.stories.tsx b/ts/components/DraftGifMessageSendModal.stories.tsx new file mode 100644 index 000000000..a22c07fa1 --- /dev/null +++ b/ts/components/DraftGifMessageSendModal.stories.tsx @@ -0,0 +1,114 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React, { useEffect, useState } from 'react'; +import type { Meta } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { + DraftGifMessageSendModal, + type DraftGifMessageSendModalProps, +} from './DraftGifMessageSendModal'; +import { ThemeType } from '../types/Util'; +import { CompositionTextArea } from './CompositionTextArea'; +import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea'; +import { EmojiSkinTone } from './fun/data/emojis'; +import { LoadingState } from '../util/loadable'; +import { VIDEO_MP4 } from '../types/MIME'; +import { drop } from '../util/drop'; + +const { i18n } = window.SignalContext; + +const MOCK_GIF_URL = + 'https://media.tenor.com/ihqN6a3iiYEAAAPo/pikachu-shocked-face-stunned.mp4'; + +export default { + title: 'components/DraftGifMessageSendModal', +} satisfies Meta; + +function RenderCompositionTextArea(props: SmartCompositionTextAreaProps) { + return ( + undefined} + i18n={i18n} + isActive + isFormattingEnabled + onPickEmoji={action('onPickEmoji')} + onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')} + onTextTooLong={action('onTextTooLong')} + ourConversationId="me" + platform="darwin" + emojiSkinToneDefault={EmojiSkinTone.None} + /> + ); +} + +export function Default(): JSX.Element { + const [file, setFile] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + + async function run() { + await new Promise(resolve => { + setTimeout(resolve, 3000); + }); + + const response = await fetch(MOCK_GIF_URL, { + signal: controller.signal, + }); + + const blob = await response.blob(); + const result = new File([blob], 'file.mp4'); + + if (!controller.signal.aborted) { + setFile(result); + } + } + + drop(run()); + + return () => { + controller.abort(); + }; + }, []); + + return ( + + ); +} diff --git a/ts/components/DraftGifMessageSendModal.tsx b/ts/components/DraftGifMessageSendModal.tsx new file mode 100644 index 000000000..ce3c39bf0 --- /dev/null +++ b/ts/components/DraftGifMessageSendModal.tsx @@ -0,0 +1,106 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React, { type ComponentType, useEffect, useMemo } from 'react'; +import { useId, VisuallyHidden } from 'react-aria'; +import type { LocalizerType } from '../types/I18N'; +import { Button, ButtonVariant } from './Button'; +import { Modal } from './Modal'; +import type { HydratedBodyRangesType } from '../types/BodyRange'; +import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea'; +import type { ThemeType } from '../types/Util'; +import { EmojiSkinTone } from './fun/data/emojis'; +import { FunGifPreview } from './fun/FunGif'; +import type { FunGifSelection } from './fun/panels/FunPanelGifs'; +import type { GifDownloadState } from '../state/smart/DraftGifMessageSendModal'; +import { LoadingState } from '../util/loadable'; + +export type DraftGifMessageSendModalProps = Readonly<{ + i18n: LocalizerType; + theme: ThemeType; + RenderCompositionTextArea: ComponentType; + draftText: string; + draftBodyRanges: HydratedBodyRangesType; + gifSelection: FunGifSelection; + gifDownloadState: GifDownloadState; + onChange: ( + messageText: string, + bodyRanges: HydratedBodyRangesType, + caretLocation?: number + ) => unknown; + onSubmit: () => void; + onClose: () => void; +}>; + +export function DraftGifMessageSendModal( + props: DraftGifMessageSendModalProps +): JSX.Element { + const { i18n, RenderCompositionTextArea } = props; + const descriptionId = useId(); + + const url = useMemo(() => { + return props.gifDownloadState.value?.file != null + ? URL.createObjectURL(props.gifDownloadState.value?.file) + : null; + }, [props.gifDownloadState]); + + useEffect(() => { + return () => { + if (url != null) { + URL.revokeObjectURL(url); + } + }; + }, [url]); + + return ( + + + {i18n('icu:DraftGifMessageSendModal__CancelButtonLabel')} + + + {i18n('icu:DraftGifMessageSendModal__SendButtonLabel')} + + > + } + > + + + + {props.gifSelection.description} + + + + + ); +} diff --git a/ts/components/ForwardMessagesModal.stories.tsx b/ts/components/ForwardMessagesModal.stories.tsx index 41f92431f..17d302ebc 100644 --- a/ts/components/ForwardMessagesModal.stories.tsx +++ b/ts/components/ForwardMessagesModal.stories.tsx @@ -15,6 +15,7 @@ import { getDefaultConversation } from '../test-both/helpers/getDefaultConversat import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext'; import { CompositionTextArea } from './CompositionTextArea'; import type { MessageForwardDraft } from '../types/ForwardDraft'; +import { EmojiSkinTone } from './fun/data/emojis'; const createAttachment = ( props: Partial = {} @@ -64,11 +65,11 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ isActive isFormattingEnabled onPickEmoji={action('onPickEmoji')} - onSetSkinTone={action('onSetSkinTone')} + onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')} onTextTooLong={action('onTextTooLong')} ourConversationId="me" platform="darwin" - skinTone={0} + emojiSkinToneDefault={EmojiSkinTone.None} /> ), showToast: action('showToast'), diff --git a/ts/components/ForwardMessagesModal.tsx b/ts/components/ForwardMessagesModal.tsx index 971047187..6df568418 100644 --- a/ts/components/ForwardMessagesModal.tsx +++ b/ts/components/ForwardMessagesModal.tsx @@ -44,6 +44,7 @@ import { } from '../types/ForwardDraft'; import { missingCaseError } from '../util/missingCaseError'; import { Theme } from '../util/theme'; +import { EmojiSkinTone } from './fun/data/emojis'; export enum ForwardMessagesModalType { Forward, @@ -500,6 +501,7 @@ function ForwardMessageEditor({ onChange={onChange} onSubmit={onSubmit} theme={theme} + emojiSkinToneDefault={EmojiSkinTone.None} /> ); diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index e0ce54505..ab10ad3c3 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -31,6 +31,7 @@ import { BackfillFailureModal, type DataPropsType as BackfillFailureModalPropsType, } from './BackfillFailureModal'; +import type { SmartDraftGifMessageSendModalProps } from '../state/smart/DraftGifMessageSendModal'; // NOTE: All types should be required for this component so that the smart // component gives you type errors when adding/removing props. @@ -80,6 +81,9 @@ export type PropsType = { // DeleteMessageModal deleteMessagesProps: DeleteMessagesPropsType | undefined; renderDeleteMessagesModal: () => JSX.Element; + // DraftGifMessageSendModal + draftGifMessageSendModalProps: SmartDraftGifMessageSendModalProps | null; + renderDraftGifMessageSendModal: () => JSX.Element; // ForwardMessageModal forwardMessagesProps: ForwardMessagesPropsType | undefined; renderForwardMessagesModal: () => JSX.Element; @@ -180,6 +184,9 @@ export function GlobalModalContainer({ // DeleteMessageModal deleteMessagesProps, renderDeleteMessagesModal, + // DraftGifMessageSendModal + draftGifMessageSendModalProps, + renderDraftGifMessageSendModal, // ForwardMessageModal forwardMessagesProps, renderForwardMessagesModal, @@ -300,6 +307,10 @@ export function GlobalModalContainer({ return renderDeleteMessagesModal(); } + if (draftGifMessageSendModalProps) { + return renderDraftGifMessageSendModal(); + } + if (messageRequestActionsConfirmationProps) { return renderMessageRequestActionsConfirmation(); } diff --git a/ts/components/MediaEditor.stories.tsx b/ts/components/MediaEditor.stories.tsx index 502399c2f..98a604e9f 100644 --- a/ts/components/MediaEditor.stories.tsx +++ b/ts/components/MediaEditor.stories.tsx @@ -8,6 +8,7 @@ import { action } from '@storybook/addon-actions'; import type { PropsType } from './MediaEditor'; import { MediaEditor } from './MediaEditor'; import { Stickers, installedPacks } from '../test-both/helpers/getStickerPacks'; +import { EmojiSkinTone } from './fun/data/emojis'; const { i18n } = window.SignalContext; const IMAGE_1 = '/fixtures/nathan-anderson-316188-unsplash.jpg'; @@ -32,7 +33,7 @@ export default { onTextTooLong: action('onTextTooLong'), platform: 'darwin', recentStickers: [Stickers.wide, Stickers.tall, Stickers.abe], - skinTone: 0, + emojiSkinToneDefault: EmojiSkinTone.None, }, } satisfies Meta; diff --git a/ts/components/MediaEditor.tsx b/ts/components/MediaEditor.tsx index 588e4b9ac..11574b4ed 100644 --- a/ts/components/MediaEditor.tsx +++ b/ts/components/MediaEditor.tsx @@ -163,9 +163,9 @@ export function MediaEditor({ sortedGroupMembers, // EmojiPickerProps - onSetSkinTone, + onEmojiSkinToneDefaultChange, recentEmojis, - skinTone, + emojiSkinToneDefault, // StickerButtonProps installedPacks, @@ -1313,7 +1313,7 @@ export function MediaEditor({ setCaptionBodyRanges(bodyRanges); setCaption(messageText); }} - skinTone={skinTone ?? null} + emojiSkinToneDefault={emojiSkinToneDefault ?? null} onPickEmoji={onPickEmoji} onSubmit={noop} onTextTooLong={onTextTooLong} @@ -1342,8 +1342,8 @@ export function MediaEditor({ onOpen={() => setEmojiPopperOpen(true)} onClose={closeEmojiPickerAndFocusComposer} recentEmojis={recentEmojis} - skinTone={skinTone} - onSetSkinTone={onSetSkinTone} + emojiSkinToneDefault={emojiSkinToneDefault} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} /> diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 1d64b0ab0..33decefc7 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -11,6 +11,7 @@ import { DEFAULT_CONVERSATION_COLOR } from '../types/Colors'; import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode'; import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability'; import { DurationInSeconds } from '../util/durations'; +import { EmojiSkinTone } from './fun/data/emojis'; const { i18n } = window.SignalContext; @@ -79,6 +80,7 @@ export default { customColors: {}, defaultConversationColor: DEFAULT_CONVERSATION_COLOR, deviceName: 'Work Windows ME', + emojiSkinToneDefault: EmojiSkinTone.None, phoneNumber: '+1 555 123-4567', hasAudioNotifications: true, hasAutoConvertEmoji: true, @@ -145,6 +147,7 @@ export default { 'onCallRingtoneNotificationChange' ), onCountMutedConversationsChange: action('onCountMutedConversationsChange'), + onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'), onHasStoriesDisabledChanged: action('onHasStoriesDisabledChanged'), onHideMenuBarChange: action('onHideMenuBarChange'), onIncomingCallNotificationsChange: action( diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index b8477d0dc..613b13eed 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -68,6 +68,8 @@ import { SearchInput } from './SearchInput'; import { removeDiacritics } from '../util/removeDiacritics'; import { assertDev } from '../util/assert'; import { I18n } from './I18n'; +import { FunSkinTonesList } from './fun/FunSkinTones'; +import { emojiParentKeyConstant, type EmojiSkinTone } from './fun/data/emojis'; type CheckboxChangeHandlerType = (value: boolean) => unknown; type SelectChangeHandlerType = (value: T) => unknown; @@ -79,6 +81,7 @@ export type PropsDataType = { customColors: Record; defaultConversationColor: DefaultConversationColorType; deviceName?: string; + emojiSkinToneDefault: EmojiSkinTone; hasAudioNotifications?: boolean; hasAutoConvertEmoji: boolean; hasAutoDownloadUpdate: boolean; @@ -172,6 +175,7 @@ type PropsFunctionType = { onCallNotificationsChange: CheckboxChangeHandlerType; onCallRingtoneNotificationChange: CheckboxChangeHandlerType; onCountMutedConversationsChange: CheckboxChangeHandlerType; + onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void; onHasStoriesDisabledChanged: SelectChangeHandlerType; onHideMenuBarChange: CheckboxChangeHandlerType; onIncomingCallNotificationsChange: CheckboxChangeHandlerType; @@ -264,6 +268,7 @@ export function Preferences({ doDeleteAllData, doneRendering, editCustomColor, + emojiSkinToneDefault, getConversationsWithCustomColor, hasAudioNotifications, hasAutoConvertEmoji, @@ -308,6 +313,7 @@ export function Preferences({ onCallNotificationsChange, onCallRingtoneNotificationChange, onCountMutedConversationsChange, + onEmojiSkinToneDefaultChange, onHasStoriesDisabledChanged, onHideMenuBarChange, onIncomingCallNotificationsChange, @@ -892,6 +898,20 @@ export function Preferences({ name="autoConvertEmoji" onChange={onAutoConvertEmojiChange} /> + + + } + /> + {isSyncSupported && ( diff --git a/ts/components/ProfileEditor.stories.tsx b/ts/components/ProfileEditor.stories.tsx index 1d4da4732..36aef8b82 100644 --- a/ts/components/ProfileEditor.stories.tsx +++ b/ts/components/ProfileEditor.stories.tsx @@ -17,6 +17,7 @@ import { } from '../state/ducks/usernameEnums'; import { getRandomColor } from '../test-both/helpers/getRandomColor'; import { SignalService as Proto } from '../protobuf'; +import { EmojiSkinTone } from './fun/data/emojis'; const { i18n } = window.SignalContext; @@ -60,13 +61,13 @@ export default { usernameLinkState: UsernameLinkState.Ready, recentEmojis: [], - skinTone: 0, + emojiSkinToneDefault: EmojiSkinTone.None, userAvatarData: [], username: undefined, onEditStateChanged: action('onEditStateChanged'), onProfileChanged: action('onProfileChanged'), - onSetSkinTone: action('onSetSkinTone'), + onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'), saveAttachment: action('saveAttachment'), setUsernameLinkColor: action('setUsernameLinkColor'), showToast: action('showToast'), @@ -107,13 +108,15 @@ function renderEditUsernameModalBody(props: { // eslint-disable-next-line react/function-component-definition const Template: StoryFn = args => { - const [skinTone, setSkinTone] = useState(0); + const [emojiSkinToneDefault, setEmojiSkinToneDefault] = useState( + EmojiSkinTone.None + ); return ( ); diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index 25ff7b321..0cc08ed8e 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -17,7 +17,6 @@ import { AvatarEditor } from './AvatarEditor'; import { AvatarPreview } from './AvatarPreview'; import { Button, ButtonVariant } from './Button'; import { ConfirmDiscardDialog } from './ConfirmDiscardDialog'; -import { Emoji } from './emoji/Emoji'; import type { Props as EmojiButtonProps } from './emoji/EmojiButton'; import { EmojiButton, EmojiButtonVariant } from './emoji/EmojiButton'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; @@ -34,7 +33,7 @@ import type { UsernameLinkState } from '../state/ducks/usernameEnums'; import { ToastType } from '../types/Toast'; import type { ShowToastAction } from '../state/ducks/toast'; import { getEmojiData, unifiedToEmoji } from './emoji/lib'; -import { assertDev } from '../util/assert'; +import { assertDev, strictAssert } from '../util/assert'; import { missingCaseError } from '../util/missingCaseError'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenu } from './ContextMenu'; @@ -48,6 +47,18 @@ import { UserText } from './UserText'; import { Tooltip, TooltipPlacement } from './Tooltip'; import { offsetDistanceModifier } from '../util/popperUtil'; import { useReducedMotion } from '../hooks/useReducedMotion'; +import { FunStaticEmoji } from './fun/FunEmoji'; +import type { EmojiSkinTone, EmojiVariantKey } from './fun/data/emojis'; +import { + getEmojiParentByKey, + getEmojiParentKeyByEnglishShortName, + getEmojiParentKeyByVariantKey, + getEmojiVariantByKey, + getEmojiVariantByParentKeyAndSkinTone, + getEmojiVariantKeyByValue, + isEmojiEnglishShortName, + isEmojiVariantValue, +} from './fun/data/emojis'; export enum EditState { None = 'None', @@ -89,12 +100,12 @@ export type PropsDataType = { usernameLinkColor?: number; usernameLink?: string; usernameLinkCorrupted: boolean; -} & Pick; +} & Pick; type PropsActionType = { deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; markCompletedUsernameLinkOnboarding: () => void; - onSetSkinTone: (tone: number) => unknown; + onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void; replaceAvatar: ReplaceAvatarActionType; saveAttachment: SaveAttachmentActionCreatorType; saveAvatarToDisk: SaveAvatarToDiskActionType; @@ -139,6 +150,20 @@ function getDefaultBios(i18n: LocalizerType): Array { ]; } +function BioEmoji(props: { emoji: EmojiVariantKey }) { + const emojiVariant = getEmojiVariantByKey(props.emoji); + const emojiParentKey = getEmojiParentKeyByVariantKey(props.emoji); + const emojiParent = getEmojiParentByKey(emojiParentKey); + return ( + + ); +} + export function ProfileEditor({ aboutEmoji, aboutText, @@ -154,7 +179,7 @@ export function ProfileEditor({ markCompletedUsernameLinkOnboarding, onEditStateChanged, onProfileChanged, - onSetSkinTone, + onEmojiSkinToneDefaultChange, openUsernameReservationModal, profileAvatarUrl, recentEmojis, @@ -167,7 +192,7 @@ export function ProfileEditor({ setUsernameEditState, setUsernameLinkColor, showToast, - skinTone, + emojiSkinToneDefault, userAvatarData, username, usernameCorrupted, @@ -226,13 +251,13 @@ export function ProfileEditor({ // To make EmojiButton re-render less often const setAboutEmoji = useCallback( (ev: EmojiPickDataType) => { - const emojiData = getEmojiData(ev.shortName, skinTone); + const emojiData = getEmojiData(ev.shortName, emojiSkinToneDefault); setStagedProfile(profileData => ({ ...profileData, aboutEmoji: unifiedToEmoji(emojiData.unified), })); }, - [setStagedProfile, skinTone] + [setStagedProfile, emojiSkinToneDefault] ); // To make AvatarEditor re-render less often @@ -416,9 +441,9 @@ export function ProfileEditor({ emoji={stagedProfile.aboutEmoji} i18n={i18n} onPickEmoji={setAboutEmoji} - onSetSkinTone={onSetSkinTone} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} recentEmojis={recentEmojis} - skinTone={skinTone} + emojiSkinToneDefault={emojiSkinToneDefault} /> } @@ -446,27 +471,44 @@ export function ProfileEditor({ whenToShowRemainingCount={40} /> - {defaultBios.map(defaultBio => ( - - - - } - label={defaultBio.i18nLabel} - onClick={() => { - const emojiData = getEmojiData(defaultBio.shortName, skinTone); + {defaultBios.map(defaultBio => { + strictAssert( + isEmojiEnglishShortName(defaultBio.shortName), + 'Must be valid english short name' + ); + const emojiParentKey = getEmojiParentKeyByEnglishShortName( + defaultBio.shortName + ); + const emojiVariant = getEmojiVariantByParentKeyAndSkinTone( + emojiParentKey, + emojiSkinToneDefault + ); - setStagedProfile(profileData => ({ - ...profileData, - aboutEmoji: unifiedToEmoji(emojiData.unified), - aboutText: defaultBio.i18nLabel, - })); - }} - /> - ))} + return ( + + + + } + label={defaultBio.i18nLabel} + onClick={() => { + const emojiData = getEmojiData( + defaultBio.shortName, + emojiSkinToneDefault + ); + + setStagedProfile(profileData => ({ + ...profileData, + aboutEmoji: unifiedToEmoji(emojiData.unified), + aboutText: defaultBio.i18nLabel, + })); + }} + /> + ); + })} - + ) : ( diff --git a/ts/components/ProfileEditorModal.tsx b/ts/components/ProfileEditorModal.tsx index bc0e6558a..b8b5c1f4f 100644 --- a/ts/components/ProfileEditorModal.tsx +++ b/ts/components/ProfileEditorModal.tsx @@ -38,7 +38,7 @@ export function ProfileEditorModal({ initialEditState, markCompletedUsernameLinkOnboarding, myProfileChanged, - onSetSkinTone, + onEmojiSkinToneDefaultChange, openUsernameReservationModal, profileAvatarUrl, recentEmojis, @@ -50,7 +50,7 @@ export function ProfileEditorModal({ setUsernameEditState, setUsernameLinkColor, showToast, - skinTone, + emojiSkinToneDefault, toggleProfileEditor, toggleProfileEditorHasError, userAvatarData, @@ -115,7 +115,7 @@ export function ProfileEditorModal({ setModalTitle(MODAL_TITLES_BY_EDIT_STATE[editState]); }} onProfileChanged={myProfileChanged} - onSetSkinTone={onSetSkinTone} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} openUsernameReservationModal={openUsernameReservationModal} profileAvatarUrl={profileAvatarUrl} recentEmojis={recentEmojis} @@ -127,7 +127,7 @@ export function ProfileEditorModal({ setUsernameEditState={setUsernameEditState} setUsernameLinkColor={setUsernameLinkColor} showToast={showToast} - skinTone={skinTone} + emojiSkinToneDefault={emojiSkinToneDefault} toggleProfileEditor={toggleProfileEditor} userAvatarData={userAvatarData} username={username} diff --git a/ts/components/ReactionPickerPicker.tsx b/ts/components/ReactionPickerPicker.tsx index ab46514a8..9af285cfb 100644 --- a/ts/components/ReactionPickerPicker.tsx +++ b/ts/components/ReactionPickerPicker.tsx @@ -5,8 +5,14 @@ import type { CSSProperties, ReactNode } from 'react'; import React, { forwardRef } from 'react'; import classNames from 'classnames'; -import { Emoji } from './emoji/Emoji'; import type { LocalizerType } from '../types/Util'; +import { FunStaticEmoji } from './fun/FunEmoji'; +import { strictAssert } from '../util/assert'; +import { + getEmojiVariantByKey, + getEmojiVariantKeyByValue, + isEmojiVariantValue, +} from './fun/data/emojis'; export enum ReactionPickerPickerStyle { Picker, @@ -25,6 +31,13 @@ export const ReactionPickerPickerEmojiButton = React.forwardRef< { emoji, onClick, isSelected, title }, ref ) { + strictAssert( + isEmojiVariantValue(emoji), + 'Expected a valid emoji variant value' + ); + const emojiVariantKey = getEmojiVariantKeyByValue(emoji); + const emojiVariant = getEmojiVariantByKey(emojiVariantKey); + return ( - + ); }); diff --git a/ts/components/StoryCreator.stories.tsx b/ts/components/StoryCreator.stories.tsx index 5b115e0d1..3105c06a7 100644 --- a/ts/components/StoryCreator.stories.tsx +++ b/ts/components/StoryCreator.stories.tsx @@ -13,6 +13,7 @@ import { getDefaultGroup, } from '../test-both/helpers/getDefaultConversation'; import { getFakeDistributionListsWithMembers } from '../test-both/helpers/getFakeDistributionLists'; +import { EmojiSkinTone } from './fun/data/emojis'; const { i18n } = window.SignalContext; @@ -38,7 +39,7 @@ export default { onDistributionListCreated: undefined, onHideMyStoriesFrom: action('onHideMyStoriesFrom'), onSend: action('onSend'), - onSetSkinTone: action('onSetSkinTone'), + onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'), onUseEmoji: action('onUseEmoji'), onViewersUpdated: action('onViewersUpdated'), processAttachment: undefined, @@ -49,7 +50,7 @@ export default { 'setMyStoriesToAllSignalConnections' ), signalConnections: Array.from(Array(42), getDefaultConversation), - skinTone: 0, + emojiSkinToneDefault: EmojiSkinTone.None, toggleSignalConnectionsModal: action('toggleSignalConnectionsModal'), }, } satisfies Meta; diff --git a/ts/components/StoryCreator.tsx b/ts/components/StoryCreator.tsx index 49c91a59a..f60423121 100644 --- a/ts/components/StoryCreator.tsx +++ b/ts/components/StoryCreator.tsx @@ -92,7 +92,10 @@ export type PropsType = { > & Pick< TextStoryCreatorPropsType, - 'onUseEmoji' | 'skinTone' | 'onSetSkinTone' | 'recentEmojis' + | 'onUseEmoji' + | 'emojiSkinToneDefault' + | 'onEmojiSkinToneDefaultChange' + | 'recentEmojis' > & Pick< MediaEditorPropsType, @@ -130,7 +133,7 @@ export function StoryCreator({ onRepliesNReactionsChanged, onSelectedStoryList, onSend, - onSetSkinTone, + onEmojiSkinToneDefaultChange, onTextTooLong, onUseEmoji, onViewersUpdated, @@ -142,7 +145,7 @@ export function StoryCreator({ sendStoryModalOpenStateChanged, setMyStoriesToAllSignalConnections, signalConnections, - skinTone, + emojiSkinToneDefault, sortedGroupMembers, theme, toggleGroupsForStorySend, @@ -277,7 +280,7 @@ export function StoryCreator({ ourConversationId={ourConversationId} platform={platform} recentStickers={recentStickers} - skinTone={skinTone} + emojiSkinToneDefault={emojiSkinToneDefault} sortedGroupMembers={sortedGroupMembers} draftText={null} draftBodyRanges={null} @@ -299,9 +302,9 @@ export function StoryCreator({ setIsReadyToSend(true); }} onUseEmoji={onUseEmoji} - onSetSkinTone={onSetSkinTone} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} recentEmojis={recentEmojis} - skinTone={skinTone} + emojiSkinToneDefault={emojiSkinToneDefault} /> )} >, diff --git a/ts/components/StoryViewer.stories.tsx b/ts/components/StoryViewer.stories.tsx index bef085f0b..001d5cef3 100644 --- a/ts/components/StoryViewer.stories.tsx +++ b/ts/components/StoryViewer.stories.tsx @@ -14,6 +14,7 @@ import { fakeAttachment } from '../test-both/helpers/fakeAttachment'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { getFakeStoryView } from '../test-both/helpers/getFakeStory'; import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants'; +import { EmojiSkinTone } from './fun/data/emojis'; const { i18n } = window.SignalContext; @@ -43,7 +44,7 @@ export default { onHideStory: action('onHideStory'), onReactToStory: action('onReactToStory'), onReplyToStory: action('onReplyToStory'), - onSetSkinTone: action('onSetSkinTone'), + onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'), onTextTooLong: action('onTextTooLong'), onUseEmoji: action('onUseEmoji'), onMediaPlaybackStart: action('onMediaPlaybackStart'), @@ -52,7 +53,7 @@ export default { renderEmojiPicker: () => <>EmojiPicker>, retryMessageSend: action('retryMessageSend'), showToast: action('showToast'), - skinTone: 0, + emojiSkinToneDefault: EmojiSkinTone.None, story: getFakeStoryView(), storyViewMode: StoryViewModeType.All, viewStory: action('viewStory'), diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index cd5b5fce4..7f72d21cf 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -54,6 +54,7 @@ import { RenderLocation } from './conversation/MessageTextRenderer'; import { arrow } from '../util/keyboard'; import { useElementId } from '../hooks/useUniqueId'; import { StoryProgressSegment } from './StoryProgressSegment'; +import type { EmojiSkinTone } from './fun/data/emojis'; function renderStrong(parts: Array) { return {parts}; @@ -92,7 +93,7 @@ export type PropsType = { numStories: number; onGoToConversation: (conversationId: string) => unknown; onHideStory: (conversationId: string) => unknown; - onSetSkinTone: (tone: number) => unknown; + onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => unknown; onTextTooLong: () => unknown; onReactToStory: (emoji: string, story: StoryViewType) => unknown; onReplyToStory: ( @@ -115,7 +116,7 @@ export type PropsType = { setHasAllStoriesUnmuted: (isUnmuted: boolean) => unknown; showContactModal: (contactId: string, conversationId?: string) => void; showToast: ShowToastAction; - skinTone?: number; + emojiSkinToneDefault: EmojiSkinTone; story: StoryViewType; storyViewMode: StoryViewModeType; viewStory: ViewStoryActionCreatorType; @@ -156,7 +157,7 @@ export function StoryViewer({ onHideStory, onReactToStory, onReplyToStory, - onSetSkinTone, + onEmojiSkinToneDefaultChange, onTextTooLong, onUseEmoji, onMediaPlaybackStart, @@ -172,7 +173,7 @@ export function StoryViewer({ setHasAllStoriesUnmuted, showContactModal, showToast, - skinTone, + emojiSkinToneDefault, story, storyViewMode, viewStory, @@ -977,7 +978,7 @@ export function StoryViewer({ } onReplyToStory(message, replyBodyRanges, replyTimestamp, story); }} - onSetSkinTone={onSetSkinTone} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} onTextTooLong={onTextTooLong} onUseEmoji={onUseEmoji} ourConversationId={ourConversationId} @@ -986,7 +987,7 @@ export function StoryViewer({ renderEmojiPicker={renderEmojiPicker} replies={replies} showContactModal={showContactModal} - skinTone={skinTone} + emojiSkinToneDefault={emojiSkinToneDefault} sortedGroupMembers={group?.sortedGroupMembers} views={views} viewTarget={currentViewTarget} diff --git a/ts/components/StoryViewsNRepliesModal.stories.tsx b/ts/components/StoryViewsNRepliesModal.stories.tsx index 111674aae..5e73e806e 100644 --- a/ts/components/StoryViewsNRepliesModal.stories.tsx +++ b/ts/components/StoryViewsNRepliesModal.stories.tsx @@ -36,7 +36,7 @@ export default { i18n, platform: 'darwin', onClose: action('onClose'), - onSetSkinTone: action('onSetSkinTone'), + onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'), onReact: action('onReact'), onReply: action('onReply'), onTextTooLong: action('onTextTooLong'), diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index 5a85548a0..4afc92237 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -37,6 +37,7 @@ import { getAvatarColor } from '../types/Colors'; import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled'; import { ContextMenu } from './ContextMenu'; import { ConfirmationDialog } from './ConfirmationDialog'; +import type { EmojiSkinTone } from './fun/data/emojis'; // Menu is disabled so these actions are inaccessible. We also don't support // link previews, tap to view messages, attachments, or gifts. Just regular @@ -107,7 +108,7 @@ export type PropsType = { bodyRanges: DraftBodyRanges, timestamp: number ) => unknown; - onSetSkinTone: (tone: number) => unknown; + onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void; onTextTooLong: () => unknown; onUseEmoji: (_: EmojiPickDataType) => unknown; ourConversationId: string | undefined; @@ -116,7 +117,7 @@ export type PropsType = { renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; replies: ReadonlyArray; showContactModal: (contactId: string, conversationId?: string) => void; - skinTone?: number; + emojiSkinToneDefault: EmojiSkinTone; sortedGroupMembers?: ReadonlyArray; views: ReadonlyArray; viewTarget: StoryViewTargetType; @@ -139,7 +140,7 @@ export function StoryViewsNRepliesModal({ onClose, onReact, onReply, - onSetSkinTone, + onEmojiSkinToneDefaultChange, onTextTooLong, onUseEmoji, ourConversationId, @@ -148,7 +149,7 @@ export function StoryViewsNRepliesModal({ renderEmojiPicker, replies, showContactModal, - skinTone, + emojiSkinToneDefault, sortedGroupMembers, viewTarget, views, @@ -238,7 +239,7 @@ export function StoryViewsNRepliesModal({ } onReact(emoji); }} - onSetSkinTone={onSetSkinTone} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} preferredReactionEmoji={preferredReactionEmoji} renderEmojiPicker={renderEmojiPicker} /> @@ -274,7 +275,7 @@ export function StoryViewsNRepliesModal({ platform={platform} quotedMessageId={null} sendCounter={0} - skinTone={skinTone ?? null} + emojiSkinToneDefault={emojiSkinToneDefault} sortedGroupMembers={sortedGroupMembers ?? null} theme={ThemeType.dark} conversationId={null} @@ -290,8 +291,8 @@ export function StoryViewsNRepliesModal({ onPickEmoji={insertEmoji} onClose={focusComposer} recentEmojis={recentEmojis} - skinTone={skinTone} - onSetSkinTone={onSetSkinTone} + emojiSkinToneDefault={emojiSkinToneDefault} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} /> diff --git a/ts/components/TextStoryCreator.tsx b/ts/components/TextStoryCreator.tsx index 2b79ac78f..6b21ea379 100644 --- a/ts/components/TextStoryCreator.tsx +++ b/ts/components/TextStoryCreator.tsx @@ -47,7 +47,10 @@ export type PropsType = { onClose: () => unknown; onDone: (textAttachment: TextAttachmentType) => unknown; onUseEmoji: (_: EmojiPickDataType) => unknown; -} & Pick; +} & Pick< + EmojiButtonPropsType, + 'onEmojiSkinToneDefaultChange' | 'recentEmojis' | 'emojiSkinToneDefault' +>; enum LinkPreviewApplied { None = 'None', @@ -138,10 +141,10 @@ export function TextStoryCreator({ linkPreview, onClose, onDone, - onSetSkinTone, + onEmojiSkinToneDefaultChange, onUseEmoji, recentEmojis, - skinTone, + emojiSkinToneDefault, }: PropsType): JSX.Element { const [showConfirmDiscardModal, setShowConfirmDiscardModal] = useState(false); @@ -452,8 +455,8 @@ export function TextStoryCreator({ ); }} recentEmojis={recentEmojis} - skinTone={skinTone} - onSetSkinTone={onSetSkinTone} + emojiSkinToneDefault={emojiSkinToneDefault} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} /> ) : ( diff --git a/ts/components/UserText.tsx b/ts/components/UserText.tsx index cb9eff4a3..00b7e5ffb 100644 --- a/ts/components/UserText.tsx +++ b/ts/components/UserText.tsx @@ -4,10 +4,14 @@ import React, { useMemo } from 'react'; import { Emojify } from './conversation/Emojify'; import { bidiIsolate } from '../util/unicodeBidi'; -export function UserText({ text }: { text: string }): JSX.Element { +export type UserTextProps = Readonly<{ + text: string; +}>; + +export function UserText(props: UserTextProps): JSX.Element { const normalizedText = useMemo(() => { - return bidiIsolate(text); - }, [text]); + return bidiIsolate(props.text); + }, [props.text]); return ( diff --git a/ts/components/conversation/Emojify.stories.tsx b/ts/components/conversation/Emojify.stories.tsx index 106bd8dca..712affd3a 100644 --- a/ts/components/conversation/Emojify.stories.tsx +++ b/ts/components/conversation/Emojify.stories.tsx @@ -12,7 +12,7 @@ export default { const createProps = (overrideProps: Partial = {}): Props => ({ renderNonEmoji: overrideProps.renderNonEmoji, - sizeClass: overrideProps.sizeClass, + fontSizeOverride: overrideProps.fontSizeOverride, text: overrideProps.text || '', }); @@ -35,7 +35,7 @@ export function SkinColorModifier(): JSX.Element { export function Jumbo(): JSX.Element { const props = createProps({ text: '😹😹😹', - sizeClass: 'max', + fontSizeOverride: 56, }); return ; @@ -44,7 +44,7 @@ export function Jumbo(): JSX.Element { export function ExtraLarge(): JSX.Element { const props = createProps({ text: '😹😹😹', - sizeClass: 'extra-large', + fontSizeOverride: 48, }); return ; @@ -53,7 +53,7 @@ export function ExtraLarge(): JSX.Element { export function Large(): JSX.Element { const props = createProps({ text: '😹😹😹', - sizeClass: 'large', + fontSizeOverride: 40, }); return ; @@ -62,7 +62,7 @@ export function Large(): JSX.Element { export function Medium(): JSX.Element { const props = createProps({ text: '😹😹😹', - sizeClass: 'medium', + fontSizeOverride: 36, }); return ; @@ -71,7 +71,7 @@ export function Medium(): JSX.Element { export function Small(): JSX.Element { const props = createProps({ text: '😹😹😹', - sizeClass: 'small', + fontSizeOverride: 32, }); return ; diff --git a/ts/components/conversation/Emojify.tsx b/ts/components/conversation/Emojify.tsx index f0b11155a..642337dbc 100644 --- a/ts/components/conversation/Emojify.tsx +++ b/ts/components/conversation/Emojify.tsx @@ -1,83 +1,59 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only - import React from 'react'; - -import classNames from 'classnames'; - import type { RenderTextCallbackType } from '../../types/Util'; import { splitByEmoji } from '../../util/emoji'; import { missingCaseError } from '../../util/missingCaseError'; -import type { SizeClassType } from '../emoji/lib'; -import { emojiToImage } from '../emoji/lib'; - -const JUMBO_SIZES = new Set(['large', 'extra-large', 'max']); - -// Some of this logic taken from emoji-js/replacement -// the DOM structure for this getImageTag should match the other emoji implementations: -// ts/components/emoji/Emoji.tsx -// ts/quill/emoji/blot.tsx -function getImageTag({ - isInvisible, - key, - match, - sizeClass, -}: { - isInvisible?: boolean; - key: string | number; - match: string; - sizeClass?: SizeClassType; -}): JSX.Element | string { - const img = emojiToImage(match); - - if (!img) { - return match; - } - - let srcSet: string | undefined; - if (sizeClass != null && JUMBO_SIZES.has(sizeClass)) { - srcSet = `emoji://jumbo?emoji=${encodeURIComponent(match)} 2x, ${img}`; - } - - return ( - - ); -} +import { FunInlineEmoji } from '../fun/FunEmoji'; +import { + getEmojiParentByKey, + getEmojiParentKeyByVariantKey, + getEmojiVariantByKey, + getEmojiVariantKeyByValue, + isEmojiVariantValue, +} from '../fun/data/emojis'; +import { strictAssert } from '../../util/assert'; export type Props = { + fontSizeOverride?: number | null; + text: string; /** When behind a spoiler, this emoji needs to be visibility: hidden */ isInvisible?: boolean; - /** A class name to be added to the generated emoji images */ - sizeClass?: SizeClassType; /** Allows you to customize now non-newlines are rendered. Simplest is just a . */ renderNonEmoji?: RenderTextCallbackType; - text: string; }; const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text; export function Emojify({ - isInvisible, - renderNonEmoji = defaultRenderNonEmoji, - sizeClass, + fontSizeOverride, text, + renderNonEmoji = defaultRenderNonEmoji, }: Props): JSX.Element { return ( <> {splitByEmoji(text).map(({ type, value: match }, index) => { if (type === 'emoji') { - return getImageTag({ isInvisible, match, sizeClass, key: index }); + strictAssert( + isEmojiVariantValue(match), + `Must be emoji variant value: ${match}` + ); + + const variantKey = getEmojiVariantKeyByValue(match); + const variant = getEmojiVariantByKey(variantKey); + const parentKey = getEmojiParentKeyByVariantKey(variantKey); + const parent = getEmojiParentByKey(parentKey); + + return ( + + ); } if (type === 'text') { diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index ab92e4e7a..b613ee9c5 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -43,7 +43,6 @@ import { Quote } from './Quote'; import { EmbeddedContact } from './EmbeddedContact'; import type { OwnProps as ReactionViewerProps } from './ReactionViewer'; import { ReactionViewer } from './ReactionViewer'; -import { Emoji } from '../emoji/Emoji'; import { LinkPreviewDate } from './LinkPreviewDate'; import type { LinkPreviewForUIType } from '../../types/message/LinkPreviews'; import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage'; @@ -105,11 +104,19 @@ import { getKeyFromCallLink } from '../../util/callLinks'; import { InAnotherCallTooltip } from './InAnotherCallTooltip'; import { formatFileSize } from '../../util/formatFileSize'; import { AttachmentNotAvailableModalType } from '../AttachmentNotAvailableModal'; -import { assertDev } from '../../util/assert'; +import { assertDev, strictAssert } from '../../util/assert'; import { AttachmentStatusIcon } from './AttachmentStatusIcon'; import { isFileDangerous } from '../../util/isFileDangerous'; import { TapToViewNotAvailableType } from '../TapToViewNotAvailableModal'; import type { DataPropsType as TapToViewNotAvailablePropsType } from '../TapToViewNotAvailableModal'; +import { FunStaticEmoji } from '../fun/FunEmoji'; +import { + getEmojiParentByKey, + getEmojiParentKeyByVariantKey, + getEmojiVariantByKey, + getEmojiVariantKeyByValue, + isEmojiVariantValue, +} from '../fun/data/emojis'; const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16; const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; @@ -224,6 +231,26 @@ export type GiftBadgeType = state: GiftBadgeStates.Failed; }; +function ReactionEmoji(props: { emojiVariantValue: string }) { + strictAssert( + isEmojiVariantValue(props.emojiVariantValue), + 'Expected a valid emoji variant value' + ); + const emojiVariantKey = getEmojiVariantKeyByValue(props.emojiVariantValue); + const emojiVariant = getEmojiVariantByKey(emojiVariantKey); + const emojiParentKey = getEmojiParentKeyByVariantKey(emojiVariantKey); + const emojiParent = getEmojiParentByKey(emojiParentKey); + + return ( + + ); +} + export type PropsData = { id: string; renderingContext: string; @@ -2885,7 +2912,7 @@ export class Message extends React.PureComponent { ) : ( <> - + {re.count > 1 ? ( ; messageText: string; @@ -56,7 +56,7 @@ export function MessageTextRenderer({ bodyRanges, direction, disableLinks, - emojiSizeClass, + jumboEmojiSize, i18n, isSpoilerExpanded, messageText, @@ -112,7 +112,7 @@ export function MessageTextRenderer({ renderNode({ direction, disableLinks, - emojiSizeClass, + jumboEmojiSize, i18n, isInvisible: false, isSpoilerExpanded, @@ -129,7 +129,7 @@ export function MessageTextRenderer({ function renderNode({ direction, disableLinks, - emojiSizeClass, + jumboEmojiSize, i18n, isInvisible, isSpoilerExpanded, @@ -140,7 +140,7 @@ function renderNode({ }: { direction: 'incoming' | 'outgoing' | undefined; disableLinks: boolean; - emojiSizeClass: SizeClassType | undefined; + jumboEmojiSize: FunJumboEmojiSize | null; i18n: LocalizerType; isInvisible: boolean; isSpoilerExpanded: Record; @@ -159,7 +159,7 @@ function renderNode({ renderNode({ direction, disableLinks, - emojiSizeClass, + jumboEmojiSize, i18n, isInvisible: isSpoilerHidden, isSpoilerExpanded, @@ -236,7 +236,7 @@ function renderNode({ let content = renderMentions({ direction, disableLinks, - emojiSizeClass, + jumboEmojiSize, isInvisible, mentions: node.mentions, onMentionTrigger, @@ -284,7 +284,7 @@ function renderNode({ function renderMentions({ direction, disableLinks, - emojiSizeClass, + jumboEmojiSize, isInvisible, mentions, node, @@ -292,7 +292,7 @@ function renderMentions({ }: { direction: 'incoming' | 'outgoing' | undefined; disableLinks: boolean; - emojiSizeClass: SizeClassType | undefined; + jumboEmojiSize: FunJumboEmojiSize | null; isInvisible: boolean; mentions: ReadonlyArray; node: DisplayNode; @@ -310,7 +310,7 @@ function renderMentions({ renderText({ isInvisible, key: result.length.toString(), - emojiSizeClass, + jumboEmojiSize, text: text.slice(offset, mention.start), }) ); @@ -337,7 +337,7 @@ function renderMentions({ renderText({ isInvisible, key: result.length.toString(), - emojiSizeClass, + jumboEmojiSize, text: text.slice(offset, text.length), }) ); @@ -401,12 +401,12 @@ function renderMention({ /** Render text that does not contain body ranges or is in between body ranges */ function renderText({ text, - emojiSizeClass, + jumboEmojiSize, isInvisible, key, }: { text: string; - emojiSizeClass: SizeClassType | undefined; + jumboEmojiSize: FunJumboEmojiSize | null; isInvisible: boolean; key: string; }) { @@ -417,7 +417,7 @@ function renderText({ renderNonEmoji={({ text: innerText, key: innerKey }) => ( )} - sizeClass={emojiSizeClass} + fontSizeOverride={jumboEmojiSize} text={text} /> ); diff --git a/ts/components/conversation/ReactionPicker.stories.tsx b/ts/components/conversation/ReactionPicker.stories.tsx index 45f0fab9c..86566c20e 100644 --- a/ts/components/conversation/ReactionPicker.stories.tsx +++ b/ts/components/conversation/ReactionPicker.stories.tsx @@ -8,22 +8,23 @@ import type { Props as ReactionPickerProps } from './ReactionPicker'; import { ReactionPicker } from './ReactionPicker'; import { EmojiPicker } from '../emoji/EmojiPicker'; import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../../reactions/constants'; +import { EmojiSkinTone } from '../fun/data/emojis'; const { i18n } = window.SignalContext; const renderEmojiPicker: ReactionPickerProps['renderEmojiPicker'] = ({ onClose, onPickEmoji, - onSetSkinTone, + onEmojiSkinToneDefaultChange, ref, }) => ( ); @@ -37,7 +38,7 @@ export function Base(): JSX.Element { & Pick< EmojiPickerProps, - 'onClickSettings' | 'onPickEmoji' | 'onSetSkinTone' + 'onClickSettings' | 'onPickEmoji' | 'onEmojiSkinToneDefaultChange' > & { ref: React.Ref; }; @@ -26,7 +27,7 @@ export type OwnProps = { selected?: string; onClose?: () => unknown; onPick: (emoji: string) => unknown; - onSetSkinTone: (tone: number) => unknown; + onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => unknown; openCustomizePreferredReactionsModal?: () => unknown; preferredReactionEmoji: ReadonlyArray; renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement; @@ -40,7 +41,7 @@ export const ReactionPicker = React.forwardRef( i18n, onClose, onPick, - onSetSkinTone, + onEmojiSkinToneDefaultChange, openCustomizePreferredReactionsModal, preferredReactionEmoji, renderEmojiPicker, @@ -82,7 +83,7 @@ export const ReactionPicker = React.forwardRef( onClickSettings: openCustomizePreferredReactionsModal, onClose, onPickEmoji, - onSetSkinTone, + onEmojiSkinToneDefaultChange, ref, style, }); diff --git a/ts/components/conversation/ReactionViewer.tsx b/ts/components/conversation/ReactionViewer.tsx index 5527a7dc9..594910027 100644 --- a/ts/components/conversation/ReactionViewer.tsx +++ b/ts/components/conversation/ReactionViewer.tsx @@ -7,7 +7,6 @@ import classNames from 'classnames'; import { ContactName } from './ContactName'; import type { Props as AvatarProps } from '../Avatar'; import { Avatar } from '../Avatar'; -import { Emoji } from '../emoji/Emoji'; import { useRestoreFocus } from '../../hooks/useRestoreFocus'; import type { ConversationType } from '../../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../../state/selectors/badges'; @@ -15,6 +14,15 @@ import type { EmojiData } from '../emoji/lib'; import { emojiToData } from '../emoji/lib'; import { useEscapeHandling } from '../../hooks/useEscapeHandling'; import type { ThemeType } from '../../types/Util'; +import { + getEmojiParentByKey, + getEmojiParentKeyByVariantKey, + getEmojiVariantByKey, + getEmojiVariantKeyByValue, + isEmojiVariantValue, +} from '../fun/data/emojis'; +import { strictAssert } from '../../util/assert'; +import { FunStaticEmoji } from '../fun/FunEmoji'; export type Reaction = { emoji: string; @@ -65,6 +73,30 @@ type ReactionCategory = { type ReactionWithEmojiData = Reaction & EmojiData; +function ReactionViewerEmoji(props: { + emojiVariantValue: string | undefined; +}): JSX.Element { + strictAssert(props.emojiVariantValue != null, 'Expected an emoji'); + strictAssert( + isEmojiVariantValue(props.emojiVariantValue), + 'Must be valid emoji variant value' + ); + const emojiVariantKey = getEmojiVariantKeyByValue(props.emojiVariantValue); + const emojiVariant = getEmojiVariantByKey(emojiVariantKey); + + const emojiParentKey = getEmojiParentKeyByVariantKey(emojiVariantKey); + const emojiParent = getEmojiParentByKey(emojiParentKey); + + return ( + + ); +} + export const ReactionViewer = React.forwardRef( function ReactionViewerInner( { @@ -207,7 +239,7 @@ export const ReactionViewer = React.forwardRef( ) : ( <> - + {count} @@ -251,7 +283,7 @@ export const ReactionViewer = React.forwardRef( )} - + ))} diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index baa75e53f..525d9c63c 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -16,6 +16,7 @@ import { WidthBreakpoint } from '../_util'; import { ThemeType } from '../../types/Util'; import { PaymentEventKind } from '../../types/Payment'; import { ErrorBoundary } from './ErrorBoundary'; +import { EmojiSkinTone } from '../fun/data/emojis'; const { i18n } = window.SignalContext; @@ -26,8 +27,10 @@ const renderEmojiPicker: TimelineItemProps['renderEmojiPicker'] = ({ }) => ( ( = React.memo( bodyRanges={displayBodyRanges} direction={undefined} disableLinks - emojiSizeClass={undefined} + jumboEmojiSize={null} i18n={i18n} isSpoilerExpanded={EMPTY_OBJECT} onMentionTrigger={noop} diff --git a/ts/components/emoji/Emoji.stories.tsx b/ts/components/emoji/Emoji.stories.tsx deleted file mode 100644 index 403f64ba3..000000000 --- a/ts/components/emoji/Emoji.stories.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as React from 'react'; -import type { Meta } from '@storybook/react'; -import type { Props } from './Emoji'; -import { Emoji, EmojiSizes } from './Emoji'; - -const tones = [0, 1, 2, 3, 4, 5]; - -export default { - title: 'Components/Emoji/Emoji', - argTypes: { - size: { control: { type: 'select' }, options: EmojiSizes }, - emoji: { control: { type: 'text' } }, - shortName: { control: { type: 'text' } }, - skinTone: { control: { type: 'select' }, options: tones }, - }, - args: { - size: 48, - emoji: '', - shortName: '', - skinTone: 0, - }, -} satisfies Meta; - -export function Sizes(args: Props): JSX.Element { - return ( - <> - {EmojiSizes.map(size => ( - - ))} - > - ); -} - -export function SkinTones(args: Props): JSX.Element { - return ( - <> - {tones.map(skinTone => ( - - ))} - > - ); -} - -export function FromEmoji(args: Props): JSX.Element { - return ; -} diff --git a/ts/components/emoji/Emoji.tsx b/ts/components/emoji/Emoji.tsx deleted file mode 100644 index 9b090c8df..000000000 --- a/ts/components/emoji/Emoji.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2019 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as React from 'react'; -import classNames from 'classnames'; -import type { SkinToneKey } from './lib'; -import { emojiToImage, getImagePath } from './lib'; - -export const EmojiSizes = [16, 18, 20, 24, 28, 32, 48, 64, 66] as const; - -export type EmojiSizeType = (typeof EmojiSizes)[number]; - -export type OwnProps = { - emoji?: string; - shortName?: string; - skinTone?: SkinToneKey | number; - size?: EmojiSizeType; - children?: React.ReactNode; - title?: string; -}; - -export type Props = OwnProps & - Pick, 'style' | 'className'>; - -// the DOM structure of this Emoji should match the other emoji implementations: -// ts/components/conversation/Emojify.tsx -// ts/quill/emoji/blot.tsx - -export const Emoji = React.memo( - React.forwardRef( - ( - { - className, - emoji, - shortName, - size = 28, - skinTone, - style = {}, - title, - }: Props, - ref - ) => { - let image = ''; - if (shortName) { - image = getImagePath(shortName, skinTone); - } else if (emoji) { - image = emojiToImage(emoji) || ''; - } - - return ( - - - - ); - } - ) -); diff --git a/ts/components/emoji/EmojiButton.stories.tsx b/ts/components/emoji/EmojiButton.stories.tsx index c079a6de2..1890841c6 100644 --- a/ts/components/emoji/EmojiButton.stories.tsx +++ b/ts/components/emoji/EmojiButton.stories.tsx @@ -6,6 +6,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import type { Props } from './EmojiButton'; import { EmojiButton } from './EmojiButton'; +import { EmojiSkinTone } from '../fun/data/emojis'; const { i18n } = window.SignalContext; @@ -26,8 +27,8 @@ export function Base(): JSX.Element { ; export type EmojiButtonAPI = Readonly<{ @@ -49,8 +59,8 @@ export const EmojiButton = React.memo(function EmojiButtonInner({ onClose, onOpen, onPickEmoji, - skinTone, - onSetSkinTone, + emojiSkinToneDefault, + onEmojiSkinToneDefaultChange, recentEmojis, variant = EmojiButtonVariant.Normal, }: Props) { @@ -150,6 +160,13 @@ export const EmojiButton = React.memo(function EmojiButtonInner({ }; }, [open, setOpen]); + let emojiVariant: EmojiVariantData; + if (emoji != null) { + strictAssert(isEmojiVariantValue(emoji), 'Must be emoji variant value'); + const emojiVariantKey = getEmojiVariantKeyByValue(emoji); + emojiVariant = getEmojiVariantByKey(emojiVariantKey); + } + return ( @@ -167,7 +184,13 @@ export const EmojiButton = React.memo(function EmojiButtonInner({ })} aria-label={i18n('icu:EmojiButton__label')} > - {emoji && } + {emojiVariant && ( + + )} )} @@ -186,8 +209,8 @@ export const EmojiButton = React.memo(function EmojiButtonInner({ } }} onClose={handleClose} - skinTone={skinTone} - onSetSkinTone={onSetSkinTone} + emojiSkinToneDefault={emojiSkinToneDefault} + onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} wasInvokedFromKeyboard={wasInvokedFromKeyboard} recentEmojis={recentEmojis} /> diff --git a/ts/components/emoji/EmojiPicker.stories.tsx b/ts/components/emoji/EmojiPicker.stories.tsx index d0bad4488..c9d0c494c 100644 --- a/ts/components/emoji/EmojiPicker.stories.tsx +++ b/ts/components/emoji/EmojiPicker.stories.tsx @@ -6,6 +6,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import type { Props } from './EmojiPicker'; import { EmojiPicker } from './EmojiPicker'; +import { EmojiSkinTone } from '../fun/data/emojis'; const { i18n } = window.SignalContext; @@ -18,9 +19,9 @@ export function Base(): JSX.Element { @@ -79,10 +80,10 @@ export function WithSettingsButton(): JSX.Element { diff --git a/ts/components/emoji/EmojiPicker.tsx b/ts/components/emoji/EmojiPicker.tsx index 1d0452619..7c57a02a3 100644 --- a/ts/components/emoji/EmojiPicker.tsx +++ b/ts/components/emoji/EmojiPicker.tsx @@ -20,26 +20,39 @@ import { } from 'lodash'; import FocusTrap from 'focus-trap-react'; -import { Emoji } from './Emoji'; import { dataByCategory } from './lib'; import type { LocalizerType } from '../../types/Util'; import { isSingleGrapheme } from '../../util/grapheme'; import { missingCaseError } from '../../util/missingCaseError'; import { useEmojiSearch } from '../../hooks/useEmojiSearch'; +import { FunStaticEmoji } from '../fun/FunEmoji'; +import { strictAssert } from '../../util/assert'; +import { + EMOJI_SKIN_TONE_ORDER, + emojiParentKeyConstant, + EmojiSkinTone, + emojiVariantConstant, + getEmojiParentKeyByEnglishShortName, + getEmojiVariantByParentKeyAndSkinTone, + isEmojiEnglishShortName, + EMOJI_SKIN_TONE_TO_NUMBER, +} from '../fun/data/emojis'; export type EmojiPickDataType = { - skinTone?: number; + skinTone: EmojiSkinTone; shortName: string; }; export type OwnProps = { readonly i18n: LocalizerType; readonly recentEmojis?: ReadonlyArray; - readonly skinTone?: number; + readonly emojiSkinToneDefault: EmojiSkinTone; readonly onClickSettings?: () => unknown; readonly onClose?: () => unknown; readonly onPickEmoji: (o: EmojiPickDataType) => unknown; - readonly onSetSkinTone?: (tone: number) => unknown; + readonly onEmojiSkinToneDefaultChange?: ( + emojiSkinTone: EmojiSkinTone + ) => void; readonly wasInvokedFromKeyboard: boolean; }; @@ -84,8 +97,8 @@ export const EmojiPicker = React.memo( { i18n, onPickEmoji, - skinTone = 0, - onSetSkinTone, + emojiSkinToneDefault = EmojiSkinTone.None, + onEmojiSkinToneDefaultChange, recentEmojis = [], style, onClickSettings, @@ -107,7 +120,8 @@ export const EmojiPicker = React.memo( const [searchMode, setSearchMode] = React.useState(false); const [searchText, setSearchText] = React.useState(''); const [scrollToRow, setScrollToRow] = React.useState(0); - const [selectedTone, setSelectedTone] = React.useState(skinTone); + const [selectedTone, setSelectedTone] = + React.useState(emojiSkinToneDefault); const search = useEmojiSearch(i18n.getLocale()); @@ -146,28 +160,6 @@ export const EmojiPicker = React.memo( [debounceSearchChange] ); - const handlePickTone = React.useCallback( - ( - e: - | React.MouseEvent - | React.KeyboardEvent - ) => { - if (isEventFromMouse(e)) { - setIsUsingKeyboard(false); - } - e.preventDefault(); - e.stopPropagation(); - - const { tone = '0' } = e.currentTarget.dataset; - const parsedTone = parseInt(tone, 10); - setSelectedTone(parsedTone); - if (onSetSkinTone) { - onSetSkinTone(parsedTone); - } - }, - [onSetSkinTone] - ); - const handlePickEmoji = React.useCallback( ( e: @@ -327,7 +319,21 @@ export const EmojiPicker = React.memo( ({ key, style: cellStyle, rowIndex, columnIndex }) => { const shortName = emojiGrid[rowIndex][columnIndex]; - return shortName ? ( + if (!shortName) { + return null; + } + + strictAssert( + isEmojiEnglishShortName(shortName), + 'Must be a valid emoji short name' + ); + const parentKey = getEmojiParentKeyByEnglishShortName(shortName); + const variantKey = getEmojiVariantByParentKeyAndSkinTone( + parentKey, + selectedTone + ); + + return ( - + - ) : null; + ); }, [emojiGrid, handlePickEmoji, selectedTone] ); @@ -505,11 +515,19 @@ export const EmojiPicker = React.memo( )} > {i18n('icu:EmojiPicker--empty')} - + + + )}