Fun picker improvements
This commit is contained in:
@@ -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"
|
||||
|
@@ -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/**/*",
|
||||
|
@@ -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;
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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;
|
||||
|
@@ -24,12 +24,6 @@
|
||||
inset-inline: 0;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.emoji-blot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
8
stylesheets/components/DraftGifMessageSendModal.scss
Normal file
8
stylesheets/components/DraftGifMessageSendModal.scss
Normal file
@@ -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;
|
||||
}
|
@@ -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)
|
||||
);
|
||||
|
@@ -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';
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
62
stylesheets/components/fun/FunGif.scss
Normal file
62
stylesheets/components/fun/FunGif.scss
Normal file
@@ -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;
|
||||
}
|
@@ -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,
|
||||
|
@@ -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;
|
||||
}
|
||||
|
33
stylesheets/components/fun/FunLightbox.scss
Normal file
33
stylesheets/components/fun/FunLightbox.scss
Normal file
@@ -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%);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
10
stylesheets/components/fun/FunSticker.scss
Normal file
10
stylesheets/components/fun/FunSticker.scss
Normal file
@@ -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;
|
||||
}
|
@@ -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;
|
||||
|
@@ -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';
|
||||
|
@@ -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({
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Emojify sizeClass="extra-large" text={emoji} />
|
||||
<FunStaticEmoji size={48} emoji={emojiVariant} role="presentation" />
|
||||
</animated.div>
|
||||
))}
|
||||
</>
|
||||
|
@@ -164,7 +164,7 @@ export function AnimatedEmoji({
|
||||
y,
|
||||
}}
|
||||
>
|
||||
<Emojify sizeClass="medium" text={value} />
|
||||
<Emojify fontSizeOverride={36} text={value} />
|
||||
</animated.div>
|
||||
);
|
||||
}
|
||||
|
@@ -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: (
|
||||
<span className="CallingReactionsToasts__reaction">
|
||||
<Emoji size={28} emoji={value} />
|
||||
<FunStaticEmoji
|
||||
role="img"
|
||||
aria-label={emojiParent.englishShortNameDefault}
|
||||
size={28}
|
||||
emoji={emojiVariant}
|
||||
/>
|
||||
{demuxId === localDemuxId ||
|
||||
(ourServiceId && conversation?.serviceId === ourServiceId)
|
||||
? i18n('icu:CallingReactions--me')
|
||||
|
@@ -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: [],
|
||||
|
@@ -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<FunGifSelection | null>(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({
|
||||
if (draftAttachments.length > 0) {
|
||||
setConfirmGifSelection(gifSelection);
|
||||
} else {
|
||||
toggleDraftGifMessageSendModal({
|
||||
conversationId,
|
||||
files: [file],
|
||||
flags: Proto.AttachmentPointer.Flags.GIF,
|
||||
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 && (
|
||||
<ConfirmationDialog
|
||||
i18n={i18n}
|
||||
dialogName="CompositionArea.ConfirmGifSelection"
|
||||
hasXButton={false}
|
||||
onClose={handleCancelGifSelection}
|
||||
onCancel={handleCancelGifSelection}
|
||||
title={i18n('icu:CompositionArea__ConfirmGifSelection__Title')}
|
||||
actions={[
|
||||
{
|
||||
action: handleConfirmGifSelection,
|
||||
style: 'affirmative',
|
||||
text: i18n(
|
||||
'icu:CompositionArea__ConfirmGifSelection__ReplaceButton'
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{i18n('icu:CompositionArea__ConfirmGifSelection__Body')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
{isFunPickerEnabled && (
|
||||
<div className="CompositionArea__button-cell">
|
||||
<FunPicker
|
||||
@@ -677,8 +729,8 @@ export const CompositionArea = memo(function CompositionArea({
|
||||
onPickEmoji={insertEmoji}
|
||||
onClose={() => setComposerFocus(conversationId)}
|
||||
recentEmojis={recentEmojis}
|
||||
skinTone={skinTone}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -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}
|
||||
/>
|
||||
|
@@ -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> = {}): 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,
|
||||
|
@@ -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<EmojiPickDataType['skinTone']> | 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,
|
||||
|
@@ -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<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'emojiSkinToneDefault'>;
|
||||
|
||||
/**
|
||||
* 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}
|
||||
/>
|
||||
</div>
|
||||
{maxLength !== undefined &&
|
||||
|
@@ -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<typeof CustomizingPreferredReactionsModal> =
|
||||
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<typeof CustomizingPreferredReactionsModal> =
|
||||
savePreferredReactions: action('savePreferredReactions'),
|
||||
selectDraftEmojiToBeReplaced: action('selectDraftEmojiToBeReplaced'),
|
||||
selectedDraftEmojiIndex: undefined,
|
||||
skinTone: 4,
|
||||
emojiSkinToneDefault: EmojiSkinTone.Type4,
|
||||
};
|
||||
|
||||
export function Default(): JSX.Element {
|
||||
|
@@ -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<string>;
|
||||
@@ -27,11 +28,11 @@ export type PropsType = {
|
||||
originalPreferredReactions: ReadonlyArray<string>;
|
||||
recentEmojis: ReadonlyArray<string>;
|
||||
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<PropsType>): JSX.Element {
|
||||
const [referenceElement, setReferenceElement] =
|
||||
useState<null | HTMLDivElement>(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();
|
||||
}}
|
||||
|
114
ts/components/DraftGifMessageSendModal.stories.tsx
Normal file
114
ts/components/DraftGifMessageSendModal.stories.tsx
Normal file
@@ -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<DraftGifMessageSendModalProps>;
|
||||
|
||||
function RenderCompositionTextArea(props: SmartCompositionTextAreaProps) {
|
||||
return (
|
||||
<CompositionTextArea
|
||||
{...props}
|
||||
getPreferredBadge={() => 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<File | null>(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 (
|
||||
<DraftGifMessageSendModal
|
||||
i18n={i18n}
|
||||
theme={ThemeType.light}
|
||||
RenderCompositionTextArea={RenderCompositionTextArea}
|
||||
draftText=""
|
||||
draftBodyRanges={[]}
|
||||
gifSelection={{
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
width: 640,
|
||||
height: 640,
|
||||
}}
|
||||
gifDownloadState={
|
||||
file == null
|
||||
? {
|
||||
loadingState: LoadingState.Loading,
|
||||
}
|
||||
: {
|
||||
loadingState: LoadingState.Loaded,
|
||||
value: {
|
||||
file,
|
||||
attachment: {
|
||||
pending: false,
|
||||
path: '',
|
||||
clientUuid: '',
|
||||
contentType: VIDEO_MP4,
|
||||
size: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
onChange={action('onChange')}
|
||||
onSubmit={action('onSubmit')}
|
||||
onClose={action('onClose')}
|
||||
/>
|
||||
);
|
||||
}
|
106
ts/components/DraftGifMessageSendModal.tsx
Normal file
106
ts/components/DraftGifMessageSendModal.tsx
Normal file
@@ -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<SmartCompositionTextAreaProps>;
|
||||
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 (
|
||||
<Modal
|
||||
i18n={i18n}
|
||||
hasXButton
|
||||
title={i18n('icu:DraftGifMessageSendModal__Title')}
|
||||
modalName="DraftGifMessageSendModal"
|
||||
moduleClassName="DraftGifMessageSendModal"
|
||||
onClose={props.onClose}
|
||||
useFocusTrap
|
||||
noMouseClose
|
||||
padded={false}
|
||||
modalFooter={
|
||||
<>
|
||||
<Button variant={ButtonVariant.Secondary} onClick={props.onClose}>
|
||||
{i18n('icu:DraftGifMessageSendModal__CancelButtonLabel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={props.onSubmit}
|
||||
disabled={
|
||||
props.gifDownloadState.loadingState !== LoadingState.Loaded
|
||||
}
|
||||
>
|
||||
{i18n('icu:DraftGifMessageSendModal__SendButtonLabel')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="DraftGifMessageSendModal__GifPreview">
|
||||
<FunGifPreview
|
||||
src={url}
|
||||
state={props.gifDownloadState.loadingState}
|
||||
width={props.gifSelection.width}
|
||||
height={props.gifSelection.height}
|
||||
maxHeight={200}
|
||||
aria-label={props.gifSelection.title}
|
||||
aria-describedby={descriptionId}
|
||||
/>
|
||||
<VisuallyHidden id={descriptionId}>
|
||||
{props.gifSelection.description}
|
||||
</VisuallyHidden>
|
||||
</div>
|
||||
<RenderCompositionTextArea
|
||||
bodyRanges={props.draftBodyRanges}
|
||||
draftText={props.draftText}
|
||||
isActive
|
||||
onChange={props.onChange}
|
||||
onSubmit={props.onSubmit}
|
||||
theme={props.theme}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@@ -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<AttachmentForUIType> = {}
|
||||
@@ -64,11 +65,11 @@ const useProps = (overrideProps: Partial<PropsType> = {}): 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'),
|
||||
|
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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<PropsType>;
|
||||
|
||||
|
@@ -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}
|
||||
/>
|
||||
</CompositionInput>
|
||||
</div>
|
||||
|
@@ -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(
|
||||
|
@@ -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<T = string | number> = (value: T) => unknown;
|
||||
@@ -79,6 +81,7 @@ export type PropsDataType = {
|
||||
customColors: Record<string, CustomColorType>;
|
||||
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<boolean>;
|
||||
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}
|
||||
/>
|
||||
<SettingsRow>
|
||||
<Control
|
||||
left={i18n('icu:Preferences__EmojiSkinToneDefaultSetting__Label')}
|
||||
right={
|
||||
<FunSkinTonesList
|
||||
i18n={i18n}
|
||||
// Raised Hand
|
||||
emoji={emojiParentKeyConstant('\u{270B}')}
|
||||
skinTone={emojiSkinToneDefault}
|
||||
onSelectSkinTone={onEmojiSkinToneDefaultChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsRow>
|
||||
{isSyncSupported && (
|
||||
<SettingsRow>
|
||||
|
@@ -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<PropsType> = args => {
|
||||
const [skinTone, setSkinTone] = useState(0);
|
||||
const [emojiSkinToneDefault, setEmojiSkinToneDefault] = useState(
|
||||
EmojiSkinTone.None
|
||||
);
|
||||
|
||||
return (
|
||||
<ProfileEditor
|
||||
{...args}
|
||||
skinTone={skinTone}
|
||||
onSetSkinTone={setSkinTone}
|
||||
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||
onEmojiSkinToneDefaultChange={setEmojiSkinToneDefault}
|
||||
renderEditUsernameModalBody={renderEditUsernameModalBody}
|
||||
/>
|
||||
);
|
||||
|
@@ -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<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'emojiSkinToneDefault'>;
|
||||
|
||||
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<DefaultBio> {
|
||||
];
|
||||
}
|
||||
|
||||
function BioEmoji(props: { emoji: EmojiVariantKey }) {
|
||||
const emojiVariant = getEmojiVariantByKey(props.emoji);
|
||||
const emojiParentKey = getEmojiParentKeyByVariantKey(props.emoji);
|
||||
const emojiParent = getEmojiParentByKey(emojiParentKey);
|
||||
return (
|
||||
<FunStaticEmoji
|
||||
role="img"
|
||||
aria-label={emojiParent.englishShortNameDefault}
|
||||
emoji={emojiVariant}
|
||||
size={24}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -446,18 +471,34 @@ export function ProfileEditor({
|
||||
whenToShowRemainingCount={40}
|
||||
/>
|
||||
|
||||
{defaultBios.map(defaultBio => (
|
||||
{defaultBios.map(defaultBio => {
|
||||
strictAssert(
|
||||
isEmojiEnglishShortName(defaultBio.shortName),
|
||||
'Must be valid english short name'
|
||||
);
|
||||
const emojiParentKey = getEmojiParentKeyByEnglishShortName(
|
||||
defaultBio.shortName
|
||||
);
|
||||
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
|
||||
emojiParentKey,
|
||||
emojiSkinToneDefault
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelRow
|
||||
className="ProfileEditor__row"
|
||||
key={defaultBio.shortName}
|
||||
icon={
|
||||
<div className="ProfileEditor__icon--container">
|
||||
<Emoji shortName={defaultBio.shortName} size={24} />
|
||||
<BioEmoji emoji={emojiVariant.key} />
|
||||
</div>
|
||||
}
|
||||
label={defaultBio.i18nLabel}
|
||||
onClick={() => {
|
||||
const emojiData = getEmojiData(defaultBio.shortName, skinTone);
|
||||
const emojiData = getEmojiData(
|
||||
defaultBio.shortName,
|
||||
emojiSkinToneDefault
|
||||
);
|
||||
|
||||
setStagedProfile(profileData => ({
|
||||
...profileData,
|
||||
@@ -466,7 +507,8 @@ export function ProfileEditor({
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<Modal.ButtonFooter>
|
||||
<Button
|
||||
@@ -712,9 +754,11 @@ export function ProfileEditor({
|
||||
<PanelRow
|
||||
className="ProfileEditor__row"
|
||||
icon={
|
||||
fullBio.aboutEmoji ? (
|
||||
fullBio.aboutEmoji && isEmojiVariantValue(fullBio.aboutEmoji) ? (
|
||||
<div className="ProfileEditor__icon--container">
|
||||
<Emoji emoji={fullBio.aboutEmoji} size={24} />
|
||||
<BioEmoji
|
||||
emoji={getEmojiVariantKeyByValue(fullBio.aboutEmoji)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--bio" />
|
||||
|
@@ -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}
|
||||
|
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
@@ -47,7 +60,12 @@ export const ReactionPickerPickerEmojiButton = React.forwardRef<
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Emoji size={48} emoji={emoji} title={title} />
|
||||
<FunStaticEmoji
|
||||
role="img"
|
||||
aria-label={title ?? ''}
|
||||
size={48}
|
||||
emoji={emojiVariant}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
@@ -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<PropsType>;
|
||||
|
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>,
|
||||
|
@@ -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'),
|
||||
|
@@ -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<JSX.Element | string>) {
|
||||
return <strong>{parts}</strong>;
|
||||
@@ -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}
|
||||
|
@@ -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'),
|
||||
|
@@ -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<ReplyType>;
|
||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||
skinTone?: number;
|
||||
emojiSkinToneDefault: EmojiSkinTone;
|
||||
sortedGroupMembers?: ReadonlyArray<ConversationType>;
|
||||
views: ReadonlyArray<StorySendStateType>;
|
||||
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}
|
||||
/>
|
||||
</CompositionInput>
|
||||
</div>
|
||||
|
@@ -47,7 +47,10 @@ export type PropsType = {
|
||||
onClose: () => unknown;
|
||||
onDone: (textAttachment: TextAttachmentType) => unknown;
|
||||
onUseEmoji: (_: EmojiPickDataType) => unknown;
|
||||
} & Pick<EmojiButtonPropsType, 'onSetSkinTone' | 'recentEmojis' | 'skinTone'>;
|
||||
} & 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}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
@@ -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 (
|
||||
<span dir="auto">
|
||||
<Emojify text={normalizedText} />
|
||||
|
@@ -12,7 +12,7 @@ export default {
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): 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 <Emojify {...props} />;
|
||||
@@ -44,7 +44,7 @@ export function Jumbo(): JSX.Element {
|
||||
export function ExtraLarge(): JSX.Element {
|
||||
const props = createProps({
|
||||
text: '😹😹😹',
|
||||
sizeClass: 'extra-large',
|
||||
fontSizeOverride: 48,
|
||||
});
|
||||
|
||||
return <Emojify {...props} />;
|
||||
@@ -53,7 +53,7 @@ export function ExtraLarge(): JSX.Element {
|
||||
export function Large(): JSX.Element {
|
||||
const props = createProps({
|
||||
text: '😹😹😹',
|
||||
sizeClass: 'large',
|
||||
fontSizeOverride: 40,
|
||||
});
|
||||
|
||||
return <Emojify {...props} />;
|
||||
@@ -62,7 +62,7 @@ export function Large(): JSX.Element {
|
||||
export function Medium(): JSX.Element {
|
||||
const props = createProps({
|
||||
text: '😹😹😹',
|
||||
sizeClass: 'medium',
|
||||
fontSizeOverride: 36,
|
||||
});
|
||||
|
||||
return <Emojify {...props} />;
|
||||
@@ -71,7 +71,7 @@ export function Medium(): JSX.Element {
|
||||
export function Small(): JSX.Element {
|
||||
const props = createProps({
|
||||
text: '😹😹😹',
|
||||
sizeClass: 'small',
|
||||
fontSizeOverride: 32,
|
||||
});
|
||||
|
||||
return <Emojify {...props} />;
|
||||
|
@@ -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<SizeClassType>(['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 (
|
||||
<img
|
||||
key={key}
|
||||
src={img}
|
||||
srcSet={srcSet}
|
||||
aria-label={match}
|
||||
className={classNames(
|
||||
'emoji',
|
||||
sizeClass,
|
||||
isInvisible ? 'emoji--invisible' : null
|
||||
)}
|
||||
alt={match}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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 <span>. */
|
||||
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 (
|
||||
<FunInlineEmoji
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
role="img"
|
||||
aria-label={parent.englishShortNameDefault}
|
||||
emoji={variant}
|
||||
size={fontSizeOverride}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'text') {
|
||||
|
@@ -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 (
|
||||
<FunStaticEmoji
|
||||
role="img"
|
||||
aria-label={emojiParent.englishShortNameDefault}
|
||||
size={16}
|
||||
emoji={emojiVariant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type PropsData = {
|
||||
id: string;
|
||||
renderingContext: string;
|
||||
@@ -2885,7 +2912,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Emoji size={16} emoji={re.emoji} />
|
||||
<ReactionEmoji emojiVariantValue={re.emoji} />
|
||||
{re.count > 1 ? (
|
||||
<span
|
||||
className={classNames(
|
||||
|
@@ -68,7 +68,7 @@ export function MessageBody({
|
||||
? `${text}...`
|
||||
: text;
|
||||
|
||||
const sizeClass = disableJumbomoji ? undefined : getSizeClass(text);
|
||||
const sizeClass = disableJumbomoji ? null : getSizeClass(text);
|
||||
|
||||
let endNotification: React.ReactNode;
|
||||
if (onIncreaseTextLength) {
|
||||
@@ -150,7 +150,7 @@ export function MessageBody({
|
||||
bodyRanges={bodyRanges ?? []}
|
||||
direction={direction}
|
||||
disableLinks={shouldDisableLinks}
|
||||
emojiSizeClass={sizeClass}
|
||||
jumboEmojiSize={sizeClass}
|
||||
i18n={i18n}
|
||||
isSpoilerExpanded={isSpoilerExpanded}
|
||||
messageText={textWithSuffix}
|
||||
|
@@ -24,8 +24,8 @@ import { AtMention } from './AtMention';
|
||||
import { isLinkSneaky } from '../../types/LinkPreview';
|
||||
import { Emojify } from './Emojify';
|
||||
import { AddNewLines } from './AddNewLines';
|
||||
import type { SizeClassType } from '../emoji/lib';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import type { FunJumboEmojiSize } from '../fun/FunEmoji';
|
||||
|
||||
const EMOJI_REGEXP = emojiRegex();
|
||||
export enum RenderLocation {
|
||||
@@ -41,7 +41,7 @@ type Props = {
|
||||
bodyRanges: BodyRangesForDisplayType;
|
||||
direction: 'incoming' | 'outgoing' | undefined;
|
||||
disableLinks: boolean;
|
||||
emojiSizeClass: SizeClassType | undefined;
|
||||
jumboEmojiSize: FunJumboEmojiSize | null;
|
||||
i18n: LocalizerType;
|
||||
isSpoilerExpanded: Record<number, boolean>;
|
||||
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<number, boolean>;
|
||||
@@ -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<HydratedBodyRangeMention>;
|
||||
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 }) => (
|
||||
<AddNewLines key={innerKey} text={innerText} />
|
||||
)}
|
||||
sizeClass={emojiSizeClass}
|
||||
fontSizeOverride={jumboEmojiSize}
|
||||
text={text}
|
||||
/>
|
||||
);
|
||||
|
@@ -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,
|
||||
}) => (
|
||||
<EmojiPicker
|
||||
i18n={i18n}
|
||||
skinTone={0}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
ref={ref}
|
||||
onClose={onClose}
|
||||
onPickEmoji={onPickEmoji}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||
wasInvokedFromKeyboard={false}
|
||||
/>
|
||||
);
|
||||
@@ -37,7 +38,7 @@ export function Base(): JSX.Element {
|
||||
<ReactionPicker
|
||||
i18n={i18n}
|
||||
onPick={action('onPick')}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
|
||||
openCustomizePreferredReactionsModal={action(
|
||||
'openCustomizePreferredReactionsModal'
|
||||
)}
|
||||
@@ -56,7 +57,9 @@ export function SelectedReaction(): JSX.Element {
|
||||
i18n={i18n}
|
||||
selected={e}
|
||||
onPick={action('onPick')}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
onEmojiSkinToneDefaultChange={action(
|
||||
'onEmojiSkinToneDefaultChange'
|
||||
)}
|
||||
openCustomizePreferredReactionsModal={action(
|
||||
'openCustomizePreferredReactionsModal'
|
||||
)}
|
||||
|
@@ -12,11 +12,12 @@ import {
|
||||
ReactionPickerPickerMoreButton,
|
||||
ReactionPickerPickerStyle,
|
||||
} from '../ReactionPickerPicker';
|
||||
import type { EmojiSkinTone } from '../fun/data/emojis';
|
||||
|
||||
export type RenderEmojiPickerProps = Pick<Props, 'onClose' | 'style'> &
|
||||
Pick<
|
||||
EmojiPickerProps,
|
||||
'onClickSettings' | 'onPickEmoji' | 'onSetSkinTone'
|
||||
'onClickSettings' | 'onPickEmoji' | 'onEmojiSkinToneDefaultChange'
|
||||
> & {
|
||||
ref: React.Ref<HTMLDivElement>;
|
||||
};
|
||||
@@ -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<string>;
|
||||
renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement;
|
||||
@@ -40,7 +41,7 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
|
||||
i18n,
|
||||
onClose,
|
||||
onPick,
|
||||
onSetSkinTone,
|
||||
onEmojiSkinToneDefaultChange,
|
||||
openCustomizePreferredReactionsModal,
|
||||
preferredReactionEmoji,
|
||||
renderEmojiPicker,
|
||||
@@ -82,7 +83,7 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
|
||||
onClickSettings: openCustomizePreferredReactionsModal,
|
||||
onClose,
|
||||
onPickEmoji,
|
||||
onSetSkinTone,
|
||||
onEmojiSkinToneDefaultChange,
|
||||
ref,
|
||||
style,
|
||||
});
|
||||
|
@@ -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 (
|
||||
<FunStaticEmoji
|
||||
role="img"
|
||||
aria-label={emojiParent.englishShortNameDefault}
|
||||
size={18}
|
||||
emoji={emojiVariant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
|
||||
function ReactionViewerInner(
|
||||
{
|
||||
@@ -207,7 +239,7 @@ export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Emoji size={18} emoji={emoji} />
|
||||
<ReactionViewerEmoji emojiVariantValue={emoji} />
|
||||
<span className="module-reaction-viewer__header__button__count">
|
||||
{count}
|
||||
</span>
|
||||
@@ -251,7 +283,7 @@ export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
|
||||
)}
|
||||
</div>
|
||||
<div className="module-reaction-viewer__body__row__emoji">
|
||||
<Emoji size={18} emoji={emoji} />
|
||||
<ReactionViewerEmoji emojiVariantValue={emoji} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
@@ -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'] = ({
|
||||
}) => (
|
||||
<EmojiPicker
|
||||
i18n={i18n}
|
||||
skinTone={0}
|
||||
onSetSkinTone={action('EmojiPicker::onSetSkinTone')}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
onEmojiSkinToneDefaultChange={action(
|
||||
'EmojiPicker::onEmojiSkinToneDefaultChange'
|
||||
)}
|
||||
ref={ref}
|
||||
onClose={onClose}
|
||||
onPickEmoji={onPickEmoji}
|
||||
|
@@ -42,6 +42,7 @@ import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
|
||||
import { ThemeType } from '../../types/Util';
|
||||
import { BadgeCategory } from '../../badges/BadgeCategory';
|
||||
import { PaymentEventKind } from '../../types/Payment';
|
||||
import { EmojiSkinTone } from '../fun/data/emojis';
|
||||
|
||||
const { i18n } = window.SignalContext;
|
||||
|
||||
@@ -112,8 +113,10 @@ const renderEmojiPicker: Props['renderEmojiPicker'] = ({
|
||||
}) => (
|
||||
<EmojiPicker
|
||||
i18n={i18n}
|
||||
skinTone={0}
|
||||
onSetSkinTone={action('EmojiPicker::onSetSkinTone')}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
onEmojiSkinToneDefaultChange={action(
|
||||
'EmojiPicker::onEmojiSkinToneDefaultChange'
|
||||
)}
|
||||
ref={ref}
|
||||
onClose={onClose}
|
||||
onPickEmoji={onPickEmoji}
|
||||
|
@@ -172,7 +172,7 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
|
||||
bodyRanges={displayBodyRanges}
|
||||
direction={undefined}
|
||||
disableLinks
|
||||
emojiSizeClass={undefined}
|
||||
jumboEmojiSize={null}
|
||||
i18n={i18n}
|
||||
isSpoilerExpanded={EMPTY_OBJECT}
|
||||
onMentionTrigger={noop}
|
||||
|
@@ -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<Props>;
|
||||
|
||||
export function Sizes(args: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{EmojiSizes.map(size => (
|
||||
<Emoji
|
||||
key={size}
|
||||
{...args}
|
||||
shortName="grinning_face_with_star_eyes"
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkinTones(args: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{tones.map(skinTone => (
|
||||
<Emoji
|
||||
key={skinTone}
|
||||
{...args}
|
||||
shortName="raised_back_of_hand"
|
||||
skinTone={skinTone}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FromEmoji(args: Props): JSX.Element {
|
||||
return <Emoji {...args} emoji="😂" />;
|
||||
}
|
@@ -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<React.HTMLProps<HTMLDivElement>, '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<HTMLDivElement, Props>(
|
||||
(
|
||||
{
|
||||
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 (
|
||||
<span
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'module-emoji',
|
||||
`module-emoji--${size}px`,
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<img
|
||||
className={`module-emoji__image--${size}px`}
|
||||
src={image}
|
||||
aria-label={title ?? emoji}
|
||||
title={title ?? emoji}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
@@ -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 {
|
||||
<EmojiButton
|
||||
i18n={i18n}
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
skinTone={0}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
|
||||
recentEmojis={[
|
||||
'grinning',
|
||||
'grin',
|
||||
|
@@ -6,13 +6,20 @@ import type { MutableRefObject } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { get, noop } from 'lodash';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import { Emoji } from './Emoji';
|
||||
import type { Props as EmojiPickerProps } from './EmojiPicker';
|
||||
import { EmojiPicker } from './EmojiPicker';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import { useRefMerger } from '../../hooks/useRefMerger';
|
||||
import { handleOutsideClick } from '../../util/handleOutsideClick';
|
||||
import * as KeyboardLayout from '../../services/keyboardLayout';
|
||||
import { FunStaticEmoji } from '../fun/FunEmoji';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import type { EmojiVariantData } from '../fun/data/emojis';
|
||||
import {
|
||||
getEmojiVariantByKey,
|
||||
getEmojiVariantKeyByValue,
|
||||
isEmojiVariantValue,
|
||||
} from '../fun/data/emojis';
|
||||
|
||||
export enum EmojiButtonVariant {
|
||||
Normal,
|
||||
@@ -33,7 +40,10 @@ export type OwnProps = Readonly<{
|
||||
export type Props = OwnProps &
|
||||
Pick<
|
||||
EmojiPickerProps,
|
||||
'onPickEmoji' | 'onSetSkinTone' | 'recentEmojis' | 'skinTone'
|
||||
| 'onPickEmoji'
|
||||
| 'onEmojiSkinToneDefaultChange'
|
||||
| 'recentEmojis'
|
||||
| 'emojiSkinToneDefault'
|
||||
>;
|
||||
|
||||
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 (
|
||||
<Manager>
|
||||
<Reference>
|
||||
@@ -167,7 +184,13 @@ export const EmojiButton = React.memo(function EmojiButtonInner({
|
||||
})}
|
||||
aria-label={i18n('icu:EmojiButton__label')}
|
||||
>
|
||||
{emoji && <Emoji emoji={emoji} size={24} />}
|
||||
{emojiVariant && (
|
||||
<FunStaticEmoji
|
||||
role="presentation"
|
||||
emoji={emojiVariant}
|
||||
size={24}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</Reference>
|
||||
@@ -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}
|
||||
/>
|
||||
|
@@ -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 {
|
||||
<EmojiPicker
|
||||
i18n={i18n}
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
|
||||
onClose={action('onClose')}
|
||||
skinTone={0}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
recentEmojis={[
|
||||
'grinning',
|
||||
'grin',
|
||||
@@ -65,9 +66,9 @@ export function NoRecents(): JSX.Element {
|
||||
<EmojiPicker
|
||||
i18n={i18n}
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
|
||||
onClose={action('onClose')}
|
||||
skinTone={0}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
recentEmojis={[]}
|
||||
wasInvokedFromKeyboard={false}
|
||||
/>
|
||||
@@ -79,10 +80,10 @@ export function WithSettingsButton(): JSX.Element {
|
||||
<EmojiPicker
|
||||
i18n={i18n}
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
|
||||
onClickSettings={action('onClickSettings')}
|
||||
onClose={action('onClose')}
|
||||
skinTone={0}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
recentEmojis={[]}
|
||||
wasInvokedFromKeyboard={false}
|
||||
/>
|
||||
|
@@ -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<string>;
|
||||
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<HTMLButtonElement>
|
||||
| React.KeyboardEvent<HTMLButtonElement>
|
||||
) => {
|
||||
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 (
|
||||
<div
|
||||
key={key}
|
||||
className="module-emoji-picker__body__emoji-cell"
|
||||
@@ -341,10 +347,14 @@ export const EmojiPicker = React.memo(
|
||||
data-short-name={shortName}
|
||||
title={shortName}
|
||||
>
|
||||
<Emoji shortName={shortName} skinTone={selectedTone} />
|
||||
<FunStaticEmoji
|
||||
role="presentation"
|
||||
emoji={variantKey}
|
||||
size={28}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
},
|
||||
[emojiGrid, handlePickEmoji, selectedTone]
|
||||
);
|
||||
@@ -505,11 +515,19 @@ export const EmojiPicker = React.memo(
|
||||
)}
|
||||
>
|
||||
{i18n('icu:EmojiPicker--empty')}
|
||||
<Emoji
|
||||
shortName="slightly_frowning_face"
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginInlineStart: '4px',
|
||||
}}
|
||||
>
|
||||
<FunStaticEmoji
|
||||
role="presentation"
|
||||
// Slightly Frowning Face
|
||||
emoji={emojiVariantConstant('\u{1F641}')}
|
||||
size={16}
|
||||
style={{ marginInlineStart: '4px' }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<footer className="module-emoji-picker__footer">
|
||||
@@ -540,34 +558,51 @@ export const EmojiPicker = React.memo(
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
{onSetSkinTone ? (
|
||||
{onEmojiSkinToneDefaultChange != null ? (
|
||||
<div className="module-emoji-picker__footer__skin-tones">
|
||||
{[0, 1, 2, 3, 4, 5].map(tone => (
|
||||
{EMOJI_SKIN_TONE_ORDER.map(emojiSkinTone => {
|
||||
return (
|
||||
<button
|
||||
aria-pressed={selectedTone === tone}
|
||||
aria-pressed={selectedTone === emojiSkinTone}
|
||||
type="button"
|
||||
key={tone}
|
||||
data-tone={tone}
|
||||
onClick={handlePickTone}
|
||||
key={emojiSkinTone}
|
||||
data-tone={emojiSkinTone}
|
||||
onClick={() => {
|
||||
setIsUsingKeyboard(false);
|
||||
setSelectedTone(emojiSkinTone);
|
||||
onEmojiSkinToneDefaultChange(emojiSkinTone);
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === 'Space') {
|
||||
handlePickTone(event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSelectedTone(emojiSkinTone);
|
||||
onEmojiSkinToneDefaultChange(emojiSkinTone);
|
||||
}
|
||||
}}
|
||||
title={i18n('icu:EmojiPicker--skin-tone', {
|
||||
tone: `${tone}`,
|
||||
tone: `${EMOJI_SKIN_TONE_TO_NUMBER.get(emojiSkinTone)}`,
|
||||
})}
|
||||
className={classNames(
|
||||
'module-emoji-picker__button',
|
||||
'module-emoji-picker__button--footer',
|
||||
selectedTone === tone
|
||||
selectedTone === emojiSkinTone
|
||||
? 'module-emoji-picker__button--selected'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<Emoji shortName="hand" skinTone={tone} size={20} />
|
||||
<FunStaticEmoji
|
||||
role="presentation"
|
||||
// Raised Hand
|
||||
emoji={getEmojiVariantByParentKeyAndSkinTone(
|
||||
emojiParentKeyConstant('\u{270B}'),
|
||||
emojiSkinTone
|
||||
)}
|
||||
size={20}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{Boolean(onClickSettings) && (
|
||||
|
@@ -8,9 +8,7 @@ import Fuse from 'fuse.js';
|
||||
import {
|
||||
compact,
|
||||
flatMap,
|
||||
get,
|
||||
groupBy,
|
||||
isNumber,
|
||||
keyBy,
|
||||
map,
|
||||
mapValues,
|
||||
@@ -19,6 +17,13 @@ import {
|
||||
} from 'lodash';
|
||||
import type { LocaleEmojiType } from '../../types/emoji';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import { FunJumboEmojiSize } from '../fun/FunEmoji';
|
||||
import {
|
||||
EMOJI_SKIN_TONE_TO_KEY,
|
||||
EmojiSkinTone,
|
||||
KEY_TO_EMOJI_SKIN_TONE,
|
||||
} from '../fun/data/emojis';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
|
||||
// Import emoji-datasource dynamically to avoid costly typechecking.
|
||||
// eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-var-requires
|
||||
@@ -27,13 +32,6 @@ const untypedData = require('emoji-datasource' as string);
|
||||
export const skinTones = ['1F3FB', '1F3FC', '1F3FD', '1F3FE', '1F3FF'];
|
||||
|
||||
export type SkinToneKey = '1F3FB' | '1F3FC' | '1F3FD' | '1F3FE' | '1F3FF';
|
||||
export type SizeClassType =
|
||||
| ''
|
||||
| 'small'
|
||||
| 'medium'
|
||||
| 'large'
|
||||
| 'extra-large'
|
||||
| 'max';
|
||||
|
||||
type EmojiSkinVariation = {
|
||||
unified: string;
|
||||
@@ -91,18 +89,7 @@ export const data = (untypedData as Array<EmojiData>)
|
||||
: emoji
|
||||
);
|
||||
|
||||
const ROOT_PATH = get(
|
||||
typeof window !== 'undefined' ? window : null,
|
||||
'ROOT_PATH',
|
||||
''
|
||||
);
|
||||
|
||||
const makeImagePath = (src: string) => {
|
||||
return `${ROOT_PATH}node_modules/emoji-datasource-apple/img/apple/64/${src}`;
|
||||
};
|
||||
|
||||
const dataByShortName = keyBy(data, 'short_name');
|
||||
const imageByEmoji: { [key: string]: string } = {};
|
||||
const dataByEmoji: { [key: string]: EmojiData } = {};
|
||||
|
||||
export const dataByCategory = mapValues(
|
||||
@@ -150,13 +137,12 @@ export const dataByCategory = mapValues(
|
||||
|
||||
export function getEmojiData(
|
||||
shortName: keyof typeof dataByShortName,
|
||||
skinTone?: SkinToneKey | number
|
||||
emojiSkinToneDefault: EmojiSkinTone
|
||||
): EmojiData | EmojiSkinVariation {
|
||||
const base = dataByShortName[shortName];
|
||||
const variation = EMOJI_SKIN_TONE_TO_KEY.get(emojiSkinToneDefault);
|
||||
|
||||
if (skinTone && base.skin_variations) {
|
||||
const variation = isNumber(skinTone) ? skinTones[skinTone - 1] : skinTone;
|
||||
|
||||
if (variation != null && base.skin_variations) {
|
||||
if (base.skin_variations[variation]) {
|
||||
return base.skin_variations[variation];
|
||||
}
|
||||
@@ -171,15 +157,6 @@ export function getEmojiData(
|
||||
return base;
|
||||
}
|
||||
|
||||
export function getImagePath(
|
||||
shortName: keyof typeof dataByShortName,
|
||||
skinTone?: SkinToneKey | number
|
||||
): string {
|
||||
const emojiData = getEmojiData(shortName, skinTone);
|
||||
|
||||
return makeImagePath(emojiData.image);
|
||||
}
|
||||
|
||||
export type SearchFnType = (query: string, count?: number) => Array<string>;
|
||||
|
||||
export type SearchEmojiListType = ReadonlyArray<
|
||||
@@ -295,7 +272,7 @@ export function unifiedToEmoji(unified: string): string {
|
||||
|
||||
export function convertShortNameToData(
|
||||
shortName: string,
|
||||
skinTone: number | SkinToneKey = 0
|
||||
skinTone: EmojiSkinTone
|
||||
): EmojiData | undefined {
|
||||
const base = dataByShortName[shortName];
|
||||
|
||||
@@ -303,10 +280,12 @@ export function convertShortNameToData(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const toneKey = isNumber(skinTone) ? skinTones[skinTone - 1] : skinTone;
|
||||
|
||||
if (skinTone && base.skin_variations) {
|
||||
const variation = base.skin_variations[toneKey];
|
||||
if (skinTone !== EmojiSkinTone.None && base.skin_variations != null) {
|
||||
const toneKey = EMOJI_SKIN_TONE_TO_KEY.get(skinTone);
|
||||
strictAssert(toneKey, `Missing key for skin tone: ${skinTone}`);
|
||||
const variation =
|
||||
base.skin_variations[toneKey] ??
|
||||
base.skin_variations[`${toneKey}-${toneKey}`];
|
||||
if (variation) {
|
||||
return {
|
||||
...base,
|
||||
@@ -320,7 +299,7 @@ export function convertShortNameToData(
|
||||
|
||||
export function convertShortName(
|
||||
shortName: string,
|
||||
skinTone: number | SkinToneKey = 0
|
||||
skinTone: EmojiSkinTone
|
||||
): string {
|
||||
const emojiData = convertShortNameToData(shortName, skinTone);
|
||||
|
||||
@@ -331,10 +310,6 @@ export function convertShortName(
|
||||
return unifiedToEmoji(emojiData.unified);
|
||||
}
|
||||
|
||||
export function emojiToImage(emoji: string): string | undefined {
|
||||
return getOwn(imageByEmoji, emoji);
|
||||
}
|
||||
|
||||
export function emojiToData(emoji: string): EmojiData | undefined {
|
||||
return getOwn(dataByEmoji, emoji);
|
||||
}
|
||||
@@ -361,34 +336,34 @@ export function hasNonEmojiText(str: string): boolean {
|
||||
return str.replace(emojiRegex(), '').trim().length > 0;
|
||||
}
|
||||
|
||||
export function getSizeClass(str: string): SizeClassType {
|
||||
export function getSizeClass(str: string): FunJumboEmojiSize | null {
|
||||
// Do we have non-emoji characters?
|
||||
if (hasNonEmojiText(str)) {
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
|
||||
const emojiCount = getEmojiCount(str);
|
||||
|
||||
if (emojiCount === 1) {
|
||||
return 'max';
|
||||
return FunJumboEmojiSize.Max;
|
||||
}
|
||||
if (emojiCount === 2) {
|
||||
return 'extra-large';
|
||||
return FunJumboEmojiSize.ExtraLarge;
|
||||
}
|
||||
if (emojiCount === 3) {
|
||||
return 'large';
|
||||
return FunJumboEmojiSize.Large;
|
||||
}
|
||||
if (emojiCount === 4) {
|
||||
return 'medium';
|
||||
return FunJumboEmojiSize.Medium;
|
||||
}
|
||||
if (emojiCount === 5) {
|
||||
return 'small';
|
||||
return FunJumboEmojiSize.Small;
|
||||
}
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
|
||||
data.forEach(emoji => {
|
||||
const { short_name, short_names, skin_variations, image } = emoji;
|
||||
const { short_name, short_names, skin_variations } = emoji;
|
||||
|
||||
if (short_names) {
|
||||
short_names.forEach(name => {
|
||||
@@ -396,14 +371,14 @@ data.forEach(emoji => {
|
||||
});
|
||||
}
|
||||
|
||||
imageByEmoji[convertShortName(short_name)] = makeImagePath(image);
|
||||
dataByEmoji[convertShortName(short_name)] = emoji;
|
||||
dataByEmoji[convertShortName(short_name, EmojiSkinTone.None)] = emoji;
|
||||
|
||||
if (skin_variations) {
|
||||
Object.entries(skin_variations).forEach(([tone, variation]) => {
|
||||
imageByEmoji[convertShortName(short_name, tone as SkinToneKey)] =
|
||||
makeImagePath(variation.image);
|
||||
dataByEmoji[convertShortName(short_name, tone as SkinToneKey)] = emoji;
|
||||
Object.entries(skin_variations).forEach(([tone]) => {
|
||||
const emojiSkinTone = KEY_TO_EMOJI_SKIN_TONE.get(tone);
|
||||
if (emojiSkinTone != null) {
|
||||
dataByEmoji[convertShortName(short_name, emojiSkinTone)] = emoji;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@@ -4,8 +4,8 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { chunk } from 'lodash';
|
||||
import React, { StrictMode, useCallback, useEffect, useRef } from 'react';
|
||||
import { type ComponentMeta } from '../../storybook/types';
|
||||
import type { FunEmojiProps } from './FunEmoji';
|
||||
import { FunEmoji } from './FunEmoji';
|
||||
import type { FunStaticEmojiProps } from './FunEmoji';
|
||||
import { FunStaticEmoji } from './FunEmoji';
|
||||
import {
|
||||
_allEmojiVariantKeys,
|
||||
getEmojiParentByKey,
|
||||
@@ -26,7 +26,7 @@ export default {
|
||||
|
||||
const COLUMNS = 8;
|
||||
|
||||
type AllProps = Pick<FunEmojiProps, 'size'>;
|
||||
type AllProps = Pick<FunStaticEmojiProps, 'size'>;
|
||||
|
||||
export function All(props: AllProps): JSX.Element {
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -102,7 +102,7 @@ export function All(props: AllProps): JSX.Element {
|
||||
key={emojiVariantKey}
|
||||
style={{ display: 'flex', outline: '1px solid' }}
|
||||
>
|
||||
<FunEmoji
|
||||
<FunStaticEmoji
|
||||
role="img"
|
||||
aria-label={parent.englishShortNameDefault}
|
||||
size={props.size}
|
||||
|
@@ -2,35 +2,170 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import type { EmojiVariantData } from './data/emojis';
|
||||
import type { FunImageAriaProps } from './types';
|
||||
|
||||
export type FunEmojiSize = 16 | 32;
|
||||
export const FUN_STATIC_EMOJI_CLASS = 'FunStaticEmoji';
|
||||
export const FUN_INLINE_EMOJI_CLASS = 'FunInlineEmoji';
|
||||
|
||||
export type FunEmojiProps = Readonly<{
|
||||
role: 'img' | 'presentation';
|
||||
'aria-label': string;
|
||||
size: FunEmojiSize;
|
||||
function getEmojiJumboUrl(emoji: EmojiVariantData): string {
|
||||
return `emoji://jumbo?emoji=${encodeURIComponent(emoji.value)}`;
|
||||
}
|
||||
|
||||
export type FunStaticEmojiSize =
|
||||
| 16
|
||||
| 18
|
||||
| 20
|
||||
| 24
|
||||
| 28
|
||||
| 32
|
||||
| 36
|
||||
| 40
|
||||
| 48
|
||||
| 56
|
||||
| 64
|
||||
| 66;
|
||||
|
||||
export enum FunJumboEmojiSize {
|
||||
Small = 32,
|
||||
Medium = 36,
|
||||
Large = 40,
|
||||
ExtraLarge = 48,
|
||||
Max = 56,
|
||||
}
|
||||
|
||||
const funStaticEmojiSizeClasses = {
|
||||
16: 'FunStaticEmoji--Size16',
|
||||
18: 'FunStaticEmoji--Size18',
|
||||
20: 'FunStaticEmoji--Size20',
|
||||
24: 'FunStaticEmoji--Size24',
|
||||
28: 'FunStaticEmoji--Size28',
|
||||
32: 'FunStaticEmoji--Size32',
|
||||
36: 'FunStaticEmoji--Size36',
|
||||
40: 'FunStaticEmoji--Size40',
|
||||
48: 'FunStaticEmoji--Size48',
|
||||
56: 'FunStaticEmoji--Size56',
|
||||
64: 'FunStaticEmoji--Size64',
|
||||
66: 'FunStaticEmoji--Size66',
|
||||
} satisfies Record<FunStaticEmojiSize, string>;
|
||||
|
||||
export type FunStaticEmojiProps = FunImageAriaProps &
|
||||
Readonly<{
|
||||
size: FunStaticEmojiSize;
|
||||
emoji: EmojiVariantData;
|
||||
}>;
|
||||
|
||||
const sizeToClassName: Record<FunEmojiSize, string> = {
|
||||
16: 'FunEmoji--Size16',
|
||||
32: 'FunEmoji--Size32',
|
||||
};
|
||||
|
||||
export function FunEmoji(props: FunEmojiProps): JSX.Element {
|
||||
export function FunStaticEmoji(props: FunStaticEmojiProps): JSX.Element {
|
||||
const emojiJumboUrl = useMemo(() => {
|
||||
return getEmojiJumboUrl(props.emoji);
|
||||
}, [props.emoji]);
|
||||
return (
|
||||
<div
|
||||
role={props.role}
|
||||
aria-label={props['aria-label']}
|
||||
className={classNames('FunEmoji', sizeToClassName[props.size])}
|
||||
data-emoji-key={props.emoji.key}
|
||||
data-emoji-value={props.emoji.value}
|
||||
className={classNames(
|
||||
FUN_STATIC_EMOJI_CLASS,
|
||||
funStaticEmojiSizeClasses[props.size]
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--fun-emoji-sheet-x': props.emoji.sheetX,
|
||||
'--fun-emoji-sheet-y': props.emoji.sheetY,
|
||||
'--fun-emoji-jumbo-image': `url(${emojiJumboUrl})`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type StaticEmojiBlotProps = FunStaticEmojiProps;
|
||||
|
||||
const TRANSPARENT_PIXEL =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E";
|
||||
|
||||
/**
|
||||
* This is for Quill. It should stay in sync with <FunStaticEmoji> as much as possible.
|
||||
*
|
||||
* The biggest difference between them is that the emoji blot uses an `<img>`
|
||||
* tag with a single transparent pixel in order to render the selection cursor
|
||||
* correctly in the browser when using `contenteditable`
|
||||
*
|
||||
* We need to use the `<img>` bec ause
|
||||
*/
|
||||
export function createStaticEmojiBlot(
|
||||
node: HTMLImageElement,
|
||||
props: StaticEmojiBlotProps
|
||||
): void {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
node.src = TRANSPARENT_PIXEL;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
node.role = props.role;
|
||||
node.classList.add(FUN_STATIC_EMOJI_CLASS);
|
||||
node.classList.add(funStaticEmojiSizeClasses[props.size]);
|
||||
node.classList.add('FunStaticEmoji--Blot');
|
||||
if (props['aria-label'] != null) {
|
||||
node.setAttribute('aria-label', props['aria-label']);
|
||||
}
|
||||
node.style.setProperty('--fun-emoji-sheet-x', `${props.emoji.sheetX}`);
|
||||
node.style.setProperty('--fun-emoji-sheet-y', `${props.emoji.sheetY}`);
|
||||
node.style.setProperty(
|
||||
'--fun-emoji-jumbo-image',
|
||||
`url(${getEmojiJumboUrl(props.emoji)})`
|
||||
);
|
||||
}
|
||||
|
||||
export type FunInlineEmojiProps = FunImageAriaProps &
|
||||
Readonly<{
|
||||
size?: number | null;
|
||||
emoji: EmojiVariantData;
|
||||
}>;
|
||||
|
||||
export function FunInlineEmoji(props: FunInlineEmojiProps): JSX.Element {
|
||||
const emojiJumboUrl = useMemo(() => {
|
||||
return getEmojiJumboUrl(props.emoji);
|
||||
}, [props.emoji]);
|
||||
return (
|
||||
<svg
|
||||
role="none"
|
||||
className={FUN_INLINE_EMOJI_CLASS}
|
||||
width={64}
|
||||
height={64}
|
||||
viewBox="0 0 64 64"
|
||||
data-emoji-key={props.emoji.key}
|
||||
data-emoji-value={props.emoji.value}
|
||||
style={
|
||||
{
|
||||
'--fun-inline-emoji-size':
|
||||
props.size != null ? `${props.size}px` : null,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{/*
|
||||
<foreignObject> is used to embed HTML+CSS within SVG, the HTML+CSS gets
|
||||
rendered at a normal size then scaled by the SVG. This allows us to make
|
||||
use of CSS features that are not supported by SVG while still using SVG's
|
||||
ability to scale relative to the parent's font-size.
|
||||
*/}
|
||||
<foreignObject x={0} y={0} width={64} height={64}>
|
||||
<span aria-hidden className="FunEmojiSelectionText">
|
||||
{props.emoji.value}
|
||||
</span>
|
||||
<span
|
||||
role={props.role}
|
||||
aria-label={props['aria-label']}
|
||||
className="FunInlineEmoji__Image"
|
||||
style={
|
||||
{
|
||||
'--fun-emoji-sheet-x': props.emoji.sheetX,
|
||||
'--fun-emoji-sheet-y': props.emoji.sheetY,
|
||||
'--fun-emoji-jumbo-image': `url(${emojiJumboUrl})`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
@@ -25,12 +25,16 @@ function Template(props: TemplateProps): JSX.Element {
|
||||
recentStickers={recentStickers}
|
||||
recentGifs={[]}
|
||||
// Emojis
|
||||
defaultEmojiSkinTone={EmojiSkinTone.None}
|
||||
onChangeDefaultEmojiSkinTone={() => null}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
onEmojiSkinToneDefaultChange={() => null}
|
||||
// Stickers
|
||||
installedStickerPacks={packs}
|
||||
showStickerPickerHint={false}
|
||||
onClearStickerPickerHint={() => null}
|
||||
// Gifs
|
||||
fetchGifsSearch={() => Promise.reject()}
|
||||
fetchGifsFeatured={() => Promise.reject()}
|
||||
fetchGif={() => Promise.reject()}
|
||||
>
|
||||
<FunEmojiPicker {...props}>
|
||||
<Button>Open EmojiPicker</Button>
|
||||
|
@@ -1,12 +1,13 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useState } from 'react';
|
||||
import type { Placement } from 'react-aria';
|
||||
import { DialogTrigger } from 'react-aria-components';
|
||||
import { FunPopover } from './base/FunPopover';
|
||||
import type { FunEmojiSelection } from './panels/FunPanelEmojis';
|
||||
import { FunPanelEmojis } from './panels/FunPanelEmojis';
|
||||
import { useFunContext } from './FunProvider';
|
||||
|
||||
export type FunEmojiPickerProps = Readonly<{
|
||||
placement?: Placement;
|
||||
@@ -20,6 +21,8 @@ export const FunEmojiPicker = memo(function FunEmojiPicker(
|
||||
props: FunEmojiPickerProps
|
||||
): JSX.Element {
|
||||
const { onOpenChange } = props;
|
||||
const fun = useFunContext();
|
||||
const { onClose } = fun;
|
||||
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
@@ -34,6 +37,12 @@ export const FunEmojiPicker = memo(function FunEmojiPicker(
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
|
||||
{props.children}
|
||||
|
111
ts/components/fun/FunGif.stories.tsx
Normal file
111
ts/components/fun/FunGif.stories.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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 { useId, VisuallyHidden } from 'react-aria';
|
||||
import { FunGif, FunGifPreview } from './FunGif';
|
||||
import { LoadingState } from '../../util/loadable';
|
||||
|
||||
export default {
|
||||
title: 'Components/Fun/FunGif',
|
||||
} satisfies Meta;
|
||||
|
||||
export function Basic(): JSX.Element {
|
||||
const id = useId();
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
|
||||
<div tabIndex={0}>
|
||||
<FunGif
|
||||
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
|
||||
width={498}
|
||||
height={376}
|
||||
aria-label="Spongebob Spongebob Squarepants GIF"
|
||||
aria-describedby={id}
|
||||
/>
|
||||
</div>
|
||||
<VisuallyHidden id={id}>
|
||||
A cartoon of spongebob wearing a top hat is laying on the ground
|
||||
</VisuallyHidden>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewSizing(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<FunGifPreview
|
||||
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
|
||||
state={LoadingState.Loaded}
|
||||
width={498}
|
||||
height={376}
|
||||
maxHeight={400}
|
||||
aria-describedby=""
|
||||
/>
|
||||
<div style={{ maxWidth: 200 }}>
|
||||
<FunGifPreview
|
||||
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
|
||||
state={LoadingState.Loaded}
|
||||
width={498}
|
||||
height={376}
|
||||
maxHeight={400}
|
||||
aria-describedby=""
|
||||
/>
|
||||
</div>
|
||||
<div style={{ maxHeight: 200 }}>
|
||||
<FunGifPreview
|
||||
src="https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4"
|
||||
state={LoadingState.Loaded}
|
||||
width={498}
|
||||
height={376}
|
||||
maxHeight={200}
|
||||
aria-describedby=""
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewLoading(): JSX.Element {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setSrc(
|
||||
'https://media.tenor.com/tN6E5iSxeI8AAAPo/spongebob-spongebob-squarepants.mp4'
|
||||
);
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FunGifPreview
|
||||
src={src}
|
||||
state={src == null ? LoadingState.Loading : LoadingState.Loaded}
|
||||
width={498}
|
||||
height={376}
|
||||
maxHeight={400}
|
||||
aria-describedby=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewError(): JSX.Element {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setError(new Error('yikes!'));
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FunGifPreview
|
||||
src={null}
|
||||
state={error == null ? LoadingState.Loading : LoadingState.LoadFailed}
|
||||
width={498}
|
||||
height={376}
|
||||
maxHeight={400}
|
||||
aria-describedby=""
|
||||
/>
|
||||
);
|
||||
}
|
183
ts/components/fun/FunGif.tsx
Normal file
183
ts/components/fun/FunGif.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { CSSProperties, ForwardedRef } from 'react';
|
||||
import React, { forwardRef, useEffect, useRef, useState } from 'react';
|
||||
import { useReducedMotion } from '@react-spring/web';
|
||||
import { SpinnerV2 } from '../SpinnerV2';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import type { Loadable } from '../../util/loadable';
|
||||
import { LoadingState } from '../../util/loadable';
|
||||
import { useIntent } from './base/FunImage';
|
||||
import * as log from '../../logging/log';
|
||||
import * as Errors from '../../types/errors';
|
||||
import { isAbortError } from '../../util/isAbortError';
|
||||
|
||||
export type FunGifProps = Readonly<{
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
'aria-label'?: string;
|
||||
'aria-describedby': string;
|
||||
ignoreReducedMotion?: boolean;
|
||||
}>;
|
||||
|
||||
export function FunGif(props: FunGifProps): JSX.Element {
|
||||
if (props.ignoreReducedMotion) {
|
||||
return <FunGifBase {...props} autoPlay />;
|
||||
}
|
||||
return <FunGifReducedMotion {...props} />;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
const FunGifBase = forwardRef(function FunGifBase(
|
||||
props: FunGifProps & { autoPlay: boolean },
|
||||
ref: ForwardedRef<HTMLVideoElement>
|
||||
) {
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
className="FunGif"
|
||||
src={props.src}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
loop
|
||||
autoPlay={props.autoPlay}
|
||||
playsInline
|
||||
muted
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
aria-label={props['aria-label']}
|
||||
aria-describedby={props['aria-describedby']}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/** @internal */
|
||||
function FunGifReducedMotion(props: FunGifProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const intent = useIntent(videoRef);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const shouldPlay = !reducedMotion || intent;
|
||||
|
||||
useEffect(() => {
|
||||
strictAssert(videoRef.current, 'Expected video element');
|
||||
const video = videoRef.current;
|
||||
if (shouldPlay) {
|
||||
video.play().catch(error => {
|
||||
// ignore errors where `play()` was interrupted by `pause()`
|
||||
if (!isAbortError(error)) {
|
||||
log.error('FunGif: Playback error', Errors.toLogFormat(error));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [shouldPlay]);
|
||||
|
||||
return <FunGifBase {...props} ref={videoRef} autoPlay={shouldPlay} />;
|
||||
}
|
||||
|
||||
export type FunGifPreviewLoadable = Loadable<string>;
|
||||
|
||||
export type FunGifPreviewProps = Readonly<{
|
||||
src: string | null;
|
||||
state: LoadingState;
|
||||
width: number;
|
||||
height: number;
|
||||
// It would be nice if this were determined by the container, but that's a
|
||||
// difficult problem because it creates a cycle where the parent's height
|
||||
// depends on its children, and its children's height depends on its parent.
|
||||
// As far as I was able to figure out, this could only be done in one dimension
|
||||
// at a time.
|
||||
maxHeight: number;
|
||||
'aria-label'?: string;
|
||||
'aria-describedby': string;
|
||||
}>;
|
||||
|
||||
export function FunGifPreview(props: FunGifPreviewProps): JSX.Element {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const [spinner, setSpinner] = useState(false);
|
||||
const [playbackError, setPlaybackError] = useState(false);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSpinner(true);
|
||||
});
|
||||
timerRef.current = timer;
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.src == null) {
|
||||
return;
|
||||
}
|
||||
strictAssert(ref.current != null, 'video ref should not be null');
|
||||
const video = ref.current;
|
||||
function onCanPlay() {
|
||||
video.hidden = false;
|
||||
clearTimeout(timerRef.current);
|
||||
setSpinner(false);
|
||||
setPlaybackError(false);
|
||||
}
|
||||
function onError() {
|
||||
clearTimeout(timerRef.current);
|
||||
setSpinner(false);
|
||||
setPlaybackError(true);
|
||||
}
|
||||
video.addEventListener('canplay', onCanPlay, { once: true });
|
||||
video.addEventListener('error', onError, { once: true });
|
||||
return () => {
|
||||
video.removeEventListener('canplay', onCanPlay);
|
||||
video.removeEventListener('error', onError);
|
||||
};
|
||||
}, [props.src]);
|
||||
|
||||
const hasError = props.state === LoadingState.LoadFailed || playbackError;
|
||||
|
||||
return (
|
||||
<div className="FunGifPreview">
|
||||
<svg
|
||||
aria-hidden
|
||||
className="FunGifPreview__Sizer"
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
style={
|
||||
{
|
||||
'--fun-gif-preview-sizer-max-height': `${props.maxHeight}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
<div className="FunGifPreview__Backdrop" role="status">
|
||||
{spinner && !hasError && (
|
||||
<SpinnerV2
|
||||
className="FunGifPreview__Spinner"
|
||||
size={36}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
)}
|
||||
{hasError && <div className="FunGifPreview__ErrorIcon" />}
|
||||
</div>
|
||||
{props.src != null && (
|
||||
<video
|
||||
ref={ref}
|
||||
className="FunGifPreview__Video"
|
||||
src={props.src}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
aria-label={props['aria-label']}
|
||||
aria-describedby={props['aria-describedby']}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -7,13 +7,15 @@ import { type ComponentMeta } from '../../storybook/types';
|
||||
import { packs, recentStickers } from '../stickers/mocks';
|
||||
import type { FunPickerProps } from './FunPicker';
|
||||
import { FunPicker } from './FunPicker';
|
||||
import type { FunProviderProps } from './FunProvider';
|
||||
import { FunProvider } from './FunProvider';
|
||||
import { MOCK_RECENT_EMOJIS } from './mocks';
|
||||
import { MOCK_GIFS_PAGINATED_ONE_PAGE, MOCK_RECENT_EMOJIS } from './mocks';
|
||||
import { EmojiSkinTone } from './data/emojis';
|
||||
|
||||
const { i18n } = window.SignalContext;
|
||||
|
||||
type TemplateProps = Omit<FunPickerProps, 'children'>;
|
||||
type TemplateProps = Omit<FunPickerProps, 'children'> &
|
||||
Pick<FunProviderProps, 'fetchGifsSearch' | 'fetchGifsFeatured' | 'fetchGif'>;
|
||||
|
||||
function Template(props: TemplateProps) {
|
||||
return (
|
||||
@@ -25,12 +27,16 @@ function Template(props: TemplateProps) {
|
||||
recentStickers={recentStickers}
|
||||
recentGifs={[]}
|
||||
// Emojis
|
||||
defaultEmojiSkinTone={EmojiSkinTone.None}
|
||||
onChangeDefaultEmojiSkinTone={() => null}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
onEmojiSkinToneDefaultChange={() => null}
|
||||
// Stickers
|
||||
installedStickerPacks={packs}
|
||||
showStickerPickerHint={false}
|
||||
onClearStickerPickerHint={() => null}
|
||||
// Gifs
|
||||
fetchGifsSearch={props.fetchGifsSearch}
|
||||
fetchGifsFeatured={props.fetchGifsFeatured}
|
||||
fetchGif={props.fetchGif}
|
||||
>
|
||||
<FunPicker {...props}>
|
||||
<Button>Open FunPicker</Button>
|
||||
@@ -47,10 +53,13 @@ export default {
|
||||
placement: 'bottom',
|
||||
defaultOpen: true,
|
||||
onOpenChange: action('onOpenChange'),
|
||||
onSelectEmoji: action('onPickEmoji'),
|
||||
onSelectSticker: action('onPickSticker'),
|
||||
onSelectGif: action('onPickGif'),
|
||||
onSelectEmoji: action('onSelectEmoji'),
|
||||
onSelectSticker: action('onSelectSticker'),
|
||||
onSelectGif: action('onSelectGif'),
|
||||
onAddStickerPack: action('onAddStickerPack'),
|
||||
fetchGifsSearch: () => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE),
|
||||
fetchGifsFeatured: () => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE),
|
||||
fetchGif: () => Promise.resolve(new Blob([new Uint8Array(1)])),
|
||||
},
|
||||
} satisfies ComponentMeta<TemplateProps>;
|
||||
|
||||
|
@@ -1,10 +1,10 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useState } from 'react';
|
||||
import type { Placement } from 'react-aria';
|
||||
import { DialogTrigger } from 'react-aria-components';
|
||||
import { FunPickerTabKey } from './FunConstants';
|
||||
import { FunPickerTabKey } from './constants';
|
||||
import { FunPopover } from './base/FunPopover';
|
||||
import { FunPickerTab, FunTabList, FunTabPanel, FunTabs } from './base/FunTabs';
|
||||
import type { FunEmojiSelection } from './panels/FunPanelEmojis';
|
||||
@@ -35,7 +35,7 @@ export const FunPicker = memo(function FunPicker(
|
||||
): JSX.Element {
|
||||
const { onOpenChange } = props;
|
||||
const fun = useFunContext();
|
||||
const { i18n } = fun;
|
||||
const { i18n, onClose } = fun;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
|
||||
|
||||
@@ -51,6 +51,12 @@ export const FunPicker = memo(function FunPicker(
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
|
||||
{props.children}
|
||||
|
@@ -14,7 +14,7 @@ import type { StickerPackType, StickerType } from '../../state/ducks/stickers';
|
||||
import type { EmojiSkinTone } from './data/emojis';
|
||||
import { EmojiPickerCategory, type EmojiParentKey } from './data/emojis';
|
||||
import type { GifType } from './panels/FunPanelGifs';
|
||||
import type { FunGifsSection, FunStickersSection } from './FunConstants';
|
||||
import type { FunGifsSection, FunStickersSection } from './constants';
|
||||
import {
|
||||
type FunEmojisSection,
|
||||
FunGifsCategory,
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
FunSectionCommon,
|
||||
FunStickersSectionBase,
|
||||
toFunStickersPackSection,
|
||||
} from './FunConstants';
|
||||
} from './constants';
|
||||
import type { fetchGifsFeatured, fetchGifsSearch } from './data/gifs';
|
||||
import type { tenorDownload } from './data/tenor';
|
||||
|
||||
export type FunContextSmartProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
@@ -33,17 +35,25 @@ export type FunContextSmartProps = Readonly<{
|
||||
recentGifs: ReadonlyArray<GifType>;
|
||||
|
||||
// Emojis
|
||||
defaultEmojiSkinTone: EmojiSkinTone;
|
||||
onChangeDefaultEmojiSkinTone: (emojiSkinTone: EmojiSkinTone) => void;
|
||||
emojiSkinToneDefault: EmojiSkinTone;
|
||||
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
|
||||
|
||||
// Stickers
|
||||
installedStickerPacks: ReadonlyArray<StickerPackType>;
|
||||
showStickerPickerHint: boolean;
|
||||
onClearStickerPickerHint: () => unknown;
|
||||
|
||||
// GIFs
|
||||
fetchGifsFeatured: typeof fetchGifsFeatured;
|
||||
fetchGifsSearch: typeof fetchGifsSearch;
|
||||
fetchGif: typeof tenorDownload;
|
||||
}>;
|
||||
|
||||
export type FunContextProps = FunContextSmartProps &
|
||||
Readonly<{
|
||||
// Open state
|
||||
onClose: () => void;
|
||||
|
||||
// Current Tab
|
||||
tab: FunPickerTabKey;
|
||||
onChangeTab: (key: FunPickerTabKey) => unknown;
|
||||
@@ -101,23 +111,14 @@ export const FunProvider = memo(function FunProvider(
|
||||
setSearchInput(newSearchInput);
|
||||
}, []);
|
||||
|
||||
// Selected Sections
|
||||
const [selectedEmojisSection, setSelectedEmojisSection] = useState(
|
||||
(): FunEmojisSection => {
|
||||
if (searchQuery !== '') {
|
||||
return FunSectionCommon.SearchResults;
|
||||
}
|
||||
const defaultEmojiSection = useMemo((): FunEmojisSection => {
|
||||
if (props.recentEmojis.length) {
|
||||
return FunSectionCommon.Recents;
|
||||
}
|
||||
return EmojiPickerCategory.SmileysAndPeople;
|
||||
}
|
||||
);
|
||||
const [selectedStickersSection, setSelectedStickersSection] = useState(
|
||||
(): FunStickersSection => {
|
||||
if (searchQuery !== '') {
|
||||
return FunSectionCommon.SearchResults;
|
||||
}
|
||||
}, [props.recentEmojis]);
|
||||
|
||||
const defaultStickerSection = useMemo((): FunStickersSection => {
|
||||
if (props.recentStickers.length > 0) {
|
||||
return FunSectionCommon.Recents;
|
||||
}
|
||||
@@ -126,6 +127,30 @@ export const FunProvider = memo(function FunProvider(
|
||||
return toFunStickersPackSection(firstInstalledStickerPack);
|
||||
}
|
||||
return FunStickersSectionBase.StickersSetup;
|
||||
}, [props.recentStickers, props.installedStickerPacks]);
|
||||
|
||||
const defaultGifsSection = useMemo((): FunGifsSection => {
|
||||
if (props.recentGifs.length > 0) {
|
||||
return FunSectionCommon.Recents;
|
||||
}
|
||||
return FunGifsCategory.Trending;
|
||||
}, [props.recentGifs]);
|
||||
|
||||
// Selected Sections
|
||||
const [selectedEmojisSection, setSelectedEmojisSection] = useState(
|
||||
(): FunEmojisSection => {
|
||||
if (searchQuery !== '') {
|
||||
return FunSectionCommon.SearchResults;
|
||||
}
|
||||
return defaultEmojiSection;
|
||||
}
|
||||
);
|
||||
const [selectedStickersSection, setSelectedStickersSection] = useState(
|
||||
(): FunStickersSection => {
|
||||
if (searchQuery !== '') {
|
||||
return FunSectionCommon.SearchResults;
|
||||
}
|
||||
return defaultStickerSection;
|
||||
}
|
||||
);
|
||||
const [selectedGifsSection, setSelectedGifsSection] = useState(
|
||||
@@ -133,10 +158,7 @@ export const FunProvider = memo(function FunProvider(
|
||||
if (searchQuery !== '') {
|
||||
return FunSectionCommon.SearchResults;
|
||||
}
|
||||
if (props.recentGifs.length > 0) {
|
||||
return FunSectionCommon.Recents;
|
||||
}
|
||||
return FunGifsCategory.Trending;
|
||||
return defaultGifsSection;
|
||||
}
|
||||
);
|
||||
const handleChangeSelectedEmojisSection = useCallback(
|
||||
@@ -158,9 +180,18 @@ export const FunProvider = memo(function FunProvider(
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSearchInput('');
|
||||
setSelectedEmojisSection(defaultEmojiSection);
|
||||
setSelectedStickersSection(defaultStickerSection);
|
||||
setSelectedGifsSection(defaultGifsSection);
|
||||
}, [defaultEmojiSection, defaultStickerSection, defaultGifsSection]);
|
||||
|
||||
return (
|
||||
<FunProviderInner
|
||||
i18n={props.i18n}
|
||||
// Open state
|
||||
onClose={handleClose}
|
||||
// Current Tab
|
||||
tab={tab}
|
||||
onChangeTab={handleChangeTab}
|
||||
@@ -179,12 +210,16 @@ export const FunProvider = memo(function FunProvider(
|
||||
recentStickers={props.recentStickers}
|
||||
recentGifs={props.recentGifs}
|
||||
// Emojis
|
||||
defaultEmojiSkinTone={props.defaultEmojiSkinTone}
|
||||
onChangeDefaultEmojiSkinTone={props.onChangeDefaultEmojiSkinTone}
|
||||
emojiSkinToneDefault={props.emojiSkinToneDefault}
|
||||
onEmojiSkinToneDefaultChange={props.onEmojiSkinToneDefaultChange}
|
||||
// Stickers
|
||||
installedStickerPacks={props.installedStickerPacks}
|
||||
showStickerPickerHint={props.showStickerPickerHint}
|
||||
onClearStickerPickerHint={props.onClearStickerPickerHint}
|
||||
// GIFs
|
||||
fetchGifsFeatured={props.fetchGifsFeatured}
|
||||
fetchGifsSearch={props.fetchGifsSearch}
|
||||
fetchGif={props.fetchGif}
|
||||
>
|
||||
{props.children}
|
||||
</FunProviderInner>
|
||||
|
@@ -3,22 +3,24 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import type { Selection } from 'react-aria-components';
|
||||
import { ListBox, ListBoxItem } from 'react-aria-components';
|
||||
import type { EmojiParentKey } from '../data/emojis';
|
||||
import type { EmojiParentKey } from './data/emojis';
|
||||
import {
|
||||
EmojiSkinTone,
|
||||
getEmojiVariantByParentKeyAndSkinTone,
|
||||
} from '../data/emojis';
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
import { FunEmoji } from '../FunEmoji';
|
||||
} from './data/emojis';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import { FunStaticEmoji } from './FunEmoji';
|
||||
import type { LocalizerType } from '../../types/I18N';
|
||||
|
||||
export type SkinTonesListBoxProps = Readonly<{
|
||||
export type FunSkinTonesListProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
emoji: EmojiParentKey;
|
||||
skinTone: EmojiSkinTone;
|
||||
onSelectSkinTone: (skinTone: EmojiSkinTone) => void;
|
||||
}>;
|
||||
|
||||
export function SkinTonesListBox(props: SkinTonesListBoxProps): JSX.Element {
|
||||
const { onSelectSkinTone } = props;
|
||||
export function FunSkinTonesList(props: FunSkinTonesListProps): JSX.Element {
|
||||
const { i18n, onSelectSkinTone } = props;
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(keys: Selection) => {
|
||||
@@ -32,50 +34,68 @@ export function SkinTonesListBox(props: SkinTonesListBoxProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<ListBox
|
||||
aria-label={i18n('icu:FunSkinTones__List')}
|
||||
className="FunSkinTones__ListBox"
|
||||
orientation="horizontal"
|
||||
selectedKeys={[props.skinTone]}
|
||||
selectionMode="single"
|
||||
disallowEmptySelection
|
||||
onSelectionChange={handleSelectionChange}
|
||||
>
|
||||
<SkinTonesListBoxItem emoji={props.emoji} skinTone={EmojiSkinTone.None} />
|
||||
<SkinTonesListBoxItem
|
||||
<FunSkinTonesListItem
|
||||
emoji={props.emoji}
|
||||
aria-label={i18n('icu:FunSkinTones__ListItem--Light')}
|
||||
skinTone={EmojiSkinTone.None}
|
||||
/>
|
||||
<FunSkinTonesListItem
|
||||
emoji={props.emoji}
|
||||
skinTone={EmojiSkinTone.Type1}
|
||||
aria-label={i18n('icu:FunSkinTones__ListItem--Light')}
|
||||
/>
|
||||
<SkinTonesListBoxItem
|
||||
<FunSkinTonesListItem
|
||||
emoji={props.emoji}
|
||||
skinTone={EmojiSkinTone.Type2}
|
||||
aria-label={i18n('icu:FunSkinTones__ListItem--MediumLight')}
|
||||
/>
|
||||
<SkinTonesListBoxItem
|
||||
<FunSkinTonesListItem
|
||||
emoji={props.emoji}
|
||||
skinTone={EmojiSkinTone.Type3}
|
||||
aria-label={i18n('icu:FunSkinTones__ListItem--Medium')}
|
||||
/>
|
||||
<SkinTonesListBoxItem
|
||||
<FunSkinTonesListItem
|
||||
emoji={props.emoji}
|
||||
skinTone={EmojiSkinTone.Type4}
|
||||
aria-label={i18n('icu:FunSkinTones__ListItem--MediumDark')}
|
||||
/>
|
||||
<SkinTonesListBoxItem
|
||||
<FunSkinTonesListItem
|
||||
emoji={props.emoji}
|
||||
skinTone={EmojiSkinTone.Type5}
|
||||
aria-label={i18n('icu:FunSkinTones__ListItem--Dark')}
|
||||
/>
|
||||
</ListBox>
|
||||
);
|
||||
}
|
||||
|
||||
type SkinTonesListBoxItemProps = Readonly<{
|
||||
type FunSkinTonesListItemProps = Readonly<{
|
||||
emoji: EmojiParentKey;
|
||||
'aria-label': string;
|
||||
skinTone: EmojiSkinTone;
|
||||
}>;
|
||||
|
||||
function SkinTonesListBoxItem(props: SkinTonesListBoxItemProps) {
|
||||
function FunSkinTonesListItem(props: FunSkinTonesListItemProps) {
|
||||
const variant = useMemo(() => {
|
||||
return getEmojiVariantByParentKeyAndSkinTone(props.emoji, props.skinTone);
|
||||
}, [props.emoji, props.skinTone]);
|
||||
|
||||
return (
|
||||
<ListBoxItem id={props.skinTone} className="FunSkinTones__ListBoxItem">
|
||||
<FunEmoji role="presentation" aria-label="" size={32} emoji={variant} />
|
||||
<ListBoxItem
|
||||
id={props.skinTone}
|
||||
className="FunSkinTones__ListBoxItem"
|
||||
aria-label={props['aria-label']}
|
||||
>
|
||||
<div className="FunSkinTones__ListBoxItemButton">
|
||||
<FunStaticEmoji role="presentation" size={32} emoji={variant} />
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
);
|
||||
}
|
36
ts/components/fun/FunSticker.stories.tsx
Normal file
36
ts/components/fun/FunSticker.stories.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React from 'react';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { FunSticker, type FunStickerProps } from './FunSticker';
|
||||
|
||||
export default {
|
||||
title: 'Components/Fun/FunSticker',
|
||||
} satisfies Meta<FunStickerProps>;
|
||||
|
||||
export function Default(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<p>with reduce motion:</p>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
|
||||
<div tabIndex={0}>
|
||||
<FunSticker
|
||||
src="/fixtures/giphy-GVNvOUpeYmI7e.gif"
|
||||
// src="/fixtures/kitten-1-64-64.jpg"
|
||||
size={68}
|
||||
role="img"
|
||||
aria-label="Sticker"
|
||||
/>
|
||||
</div>
|
||||
<p>without reduce motion:</p>
|
||||
<FunSticker
|
||||
src="/fixtures/giphy-GVNvOUpeYmI7e.gif"
|
||||
// src="/fixtures/kitten-1-64-64.jpg"
|
||||
size={68}
|
||||
role="img"
|
||||
aria-label="Sticker"
|
||||
ignoreReducedMotion
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
26
ts/components/fun/FunSticker.tsx
Normal file
26
ts/components/fun/FunSticker.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React from 'react';
|
||||
import { FunImage } from './base/FunImage';
|
||||
import type { FunImageAriaProps } from './types';
|
||||
|
||||
export type FunStickerProps = FunImageAriaProps &
|
||||
Readonly<{
|
||||
src: string;
|
||||
size: number;
|
||||
ignoreReducedMotion?: boolean;
|
||||
}>;
|
||||
|
||||
export function FunSticker(props: FunStickerProps): JSX.Element {
|
||||
const { src, size, ignoreReducedMotion, ...ariaProps } = props;
|
||||
return (
|
||||
<FunImage
|
||||
{...ariaProps}
|
||||
className="FunItem__Sticker"
|
||||
src={src}
|
||||
width={size}
|
||||
height={size}
|
||||
ignoreReducedMotion={ignoreReducedMotion}
|
||||
/>
|
||||
);
|
||||
}
|
62
ts/components/fun/FunStickerPicker.stories.tsx
Normal file
62
ts/components/fun/FunStickerPicker.stories.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React, { StrictMode } from 'react';
|
||||
import { Button } from 'react-aria-components';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import enMessages from '../../../_locales/en/messages.json';
|
||||
import { type ComponentMeta } from '../../storybook/types';
|
||||
import { setupI18n } from '../../util/setupI18n';
|
||||
import type { FunStickerPickerProps } from './FunStickerPicker';
|
||||
import { FunStickerPicker } from './FunStickerPicker';
|
||||
import { MOCK_RECENT_EMOJIS } from './mocks';
|
||||
import { FunProvider } from './FunProvider';
|
||||
import { packs, recentStickers } from '../stickers/mocks';
|
||||
import { EmojiSkinTone } from './data/emojis';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
type TemplateProps = Omit<FunStickerPickerProps, 'children'>;
|
||||
|
||||
function Template(props: TemplateProps): JSX.Element {
|
||||
return (
|
||||
<StrictMode>
|
||||
<FunProvider
|
||||
i18n={i18n}
|
||||
// Recents
|
||||
recentEmojis={MOCK_RECENT_EMOJIS}
|
||||
recentStickers={recentStickers}
|
||||
recentGifs={[]}
|
||||
// Emojis
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
onEmojiSkinToneDefaultChange={() => null}
|
||||
// Stickers
|
||||
installedStickerPacks={packs}
|
||||
showStickerPickerHint={false}
|
||||
onClearStickerPickerHint={() => null}
|
||||
// Gifs
|
||||
fetchGifsSearch={() => Promise.reject()}
|
||||
fetchGifsFeatured={() => Promise.reject()}
|
||||
fetchGif={() => Promise.reject()}
|
||||
>
|
||||
<FunStickerPicker {...props}>
|
||||
<Button>Open StickerPicker</Button>
|
||||
</FunStickerPicker>
|
||||
</FunProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Components/Fun/FunStickerPicker',
|
||||
component: Template,
|
||||
args: {
|
||||
placement: 'bottom',
|
||||
defaultOpen: true,
|
||||
onSelectSticker: action('onSelectSticker'),
|
||||
onOpenChange: action('onOpenChange'),
|
||||
},
|
||||
} satisfies ComponentMeta<TemplateProps>;
|
||||
|
||||
export function Default(props: TemplateProps): JSX.Element {
|
||||
return <Template {...props} />;
|
||||
}
|
58
ts/components/fun/FunStickerPicker.tsx
Normal file
58
ts/components/fun/FunStickerPicker.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useState } from 'react';
|
||||
import type { Placement } from 'react-aria';
|
||||
import { DialogTrigger } from 'react-aria-components';
|
||||
import { FunPopover } from './base/FunPopover';
|
||||
import type { FunStickerSelection } from './panels/FunPanelStickers';
|
||||
import { FunPanelStickers } from './panels/FunPanelStickers';
|
||||
import { useFunContext } from './FunProvider';
|
||||
|
||||
export type FunStickerPickerProps = Readonly<{
|
||||
placement?: Placement;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
onSelectSticker: (stickerSelection: FunStickerSelection) => void;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const FunStickerPicker = memo(function FunStickerPicker(
|
||||
props: FunStickerPickerProps
|
||||
): JSX.Element {
|
||||
const { onOpenChange } = props;
|
||||
const fun = useFunContext();
|
||||
const { onClose } = fun;
|
||||
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextIsOpen: boolean) => {
|
||||
setIsOpen(nextIsOpen);
|
||||
onOpenChange?.(nextIsOpen);
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
|
||||
{props.children}
|
||||
<FunPopover placement={props.placement}>
|
||||
<FunPanelStickers
|
||||
onSelectSticker={props.onSelectSticker}
|
||||
onClose={handleClose}
|
||||
onAddStickerPack={null}
|
||||
/>
|
||||
</FunPopover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
});
|
@@ -1,22 +1,50 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { RefObject } from 'react';
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import type { ForwardedRef, RefObject } from 'react';
|
||||
import React, { useRef, useEffect, useState, forwardRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { isFocusable } from '@react-aria/focus';
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
import { useReducedMotion } from '../../../hooks/useReducedMotion';
|
||||
import type { FunImageAriaProps } from '../types';
|
||||
|
||||
export type FunAnimatedImageProps = Readonly<{
|
||||
role: 'image' | 'presentation';
|
||||
export type FunImageProps = FunImageAriaProps &
|
||||
Readonly<{
|
||||
className?: string;
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
alt: string;
|
||||
ignoreReducedMotion?: boolean;
|
||||
}>;
|
||||
|
||||
export function FunImage(props: FunAnimatedImageProps): JSX.Element {
|
||||
export function FunImage(props: FunImageProps): JSX.Element {
|
||||
if (props.ignoreReducedMotion) {
|
||||
return <FunImageBase {...props} />;
|
||||
}
|
||||
return <FunImageReducedMotion {...props} />;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
const FunImageBase = forwardRef(function FunImageBase(
|
||||
props: FunImageProps,
|
||||
ref: ForwardedRef<HTMLImageElement>
|
||||
) {
|
||||
return (
|
||||
<img
|
||||
ref={ref}
|
||||
role={props.role}
|
||||
aria-label={props['aria-label']}
|
||||
className={props.className}
|
||||
src={props.src}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/** @internal */
|
||||
function FunImageReducedMotion(props: FunImageProps) {
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const intent = useIntent(imageRef);
|
||||
const [staticSource, setStaticSource] = useState<string | null>(null);
|
||||
@@ -69,16 +97,7 @@ export function FunImage(props: FunAnimatedImageProps): JSX.Element {
|
||||
{staticSource != null && reducedMotion && !intent && (
|
||||
<source className="FunImage--StaticSource" srcSet={staticSource} />
|
||||
)}
|
||||
{/* Using <img> to benefit from browser */}
|
||||
<img
|
||||
ref={imageRef}
|
||||
role={props.role}
|
||||
className={props.className}
|
||||
src={props.src}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
alt={props.alt}
|
||||
/>
|
||||
<FunImageBase {...props} ref={imageRef} />
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
@@ -108,7 +127,7 @@ function closestElement(
|
||||
* - However, this will break if elements become focusable/unfocusable during
|
||||
* their lifetime (this is generally a sign something is being done wrong).
|
||||
*/
|
||||
function useIntent(ref: RefObject<HTMLElement>): boolean {
|
||||
export function useIntent(ref: RefObject<HTMLElement>): boolean {
|
||||
const [intent, setIntent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
@@ -1,8 +1,7 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { FunImage } from './FunImage';
|
||||
|
||||
/**
|
||||
* Button
|
||||
@@ -10,9 +9,8 @@ import { FunImage } from './FunImage';
|
||||
|
||||
export type FunItemButtonProps = Readonly<{
|
||||
'aria-label': string;
|
||||
'aria-describedby'?: string;
|
||||
tabIndex: number;
|
||||
onClick: (event: MouseEvent) => void;
|
||||
onClick: (event: ReactMouseEvent) => void;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
@@ -22,7 +20,6 @@ export function FunItemButton(props: FunItemButtonProps): JSX.Element {
|
||||
type="button"
|
||||
className="FunItem__Button"
|
||||
aria-label={props['aria-label']}
|
||||
aria-describedby={props['aria-describedby']}
|
||||
onClick={props.onClick}
|
||||
tabIndex={props.tabIndex}
|
||||
>
|
||||
@@ -30,48 +27,3 @@ export function FunItemButton(props: FunItemButtonProps): JSX.Element {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticker
|
||||
*/
|
||||
|
||||
export type FunItemStickerProps = Readonly<{
|
||||
src: string;
|
||||
}>;
|
||||
|
||||
export function FunItemSticker(props: FunItemStickerProps): JSX.Element {
|
||||
return (
|
||||
<FunImage
|
||||
role="presentation"
|
||||
className="FunItem__Sticker"
|
||||
src={props.src}
|
||||
width={68}
|
||||
height={68}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gif
|
||||
*/
|
||||
|
||||
export type FunItemGifProps = Readonly<{
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
|
||||
export function FunItemGif(props: FunItemGifProps): JSX.Element {
|
||||
return (
|
||||
<FunImage
|
||||
role="presentation"
|
||||
className="FunItem__Gif"
|
||||
src={props.src}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
// For presentation only
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
158
ts/components/fun/base/FunLightbox.tsx
Normal file
158
ts/components/fun/base/FunLightbox.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
|
||||
/**
|
||||
* Tracks the current `data-key` that has a long-press/long-focus
|
||||
*/
|
||||
const FunLightboxKeyContext = createContext<string | null>(null);
|
||||
|
||||
export function useFunLightboxKey(): string | null {
|
||||
return useContext(FunLightboxKeyContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider
|
||||
*/
|
||||
|
||||
export type FunLightboxProviderProps = Readonly<{
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export function FunLightboxProvider(
|
||||
props: FunLightboxProviderProps
|
||||
): JSX.Element {
|
||||
const [lightboxKey, setLightboxKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
strictAssert(props.containerRef.current, 'Missing container ref');
|
||||
const container = props.containerRef.current;
|
||||
|
||||
let isLongPressed = false;
|
||||
let lastLongPress: number | null = null;
|
||||
let currentKey: string | null;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
function lookupKey(event: Event): string | null {
|
||||
if (!(event.target instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const closest = event.target.closest('[data-key]');
|
||||
if (!(closest instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const { key } = closest.dataset;
|
||||
strictAssert(key, 'Must have key');
|
||||
return key;
|
||||
}
|
||||
|
||||
function update() {
|
||||
if (isLongPressed && currentKey != null) {
|
||||
setLightboxKey(currentKey);
|
||||
} else {
|
||||
setLightboxKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
currentKey = lookupKey(event);
|
||||
timer = setTimeout(() => {
|
||||
isLongPressed = true;
|
||||
update();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onMouseUp(event: MouseEvent) {
|
||||
clearTimeout(timer);
|
||||
if (isLongPressed) {
|
||||
lastLongPress = event.timeStamp;
|
||||
isLongPressed = false;
|
||||
currentKey = null;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
const foundKey = lookupKey(event);
|
||||
if (foundKey != null) {
|
||||
currentKey = lookupKey(event);
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (event.timeStamp === lastLongPress) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('mousedown', onMouseDown);
|
||||
container.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
window.addEventListener('click', onClick, { capture: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('mousedown', onMouseDown);
|
||||
container.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
window.addEventListener('click', onClick, { capture: true });
|
||||
};
|
||||
}, [props.containerRef]);
|
||||
|
||||
return (
|
||||
<FunLightboxKeyContext.Provider value={lightboxKey}>
|
||||
{props.children}
|
||||
</FunLightboxKeyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal
|
||||
*/
|
||||
|
||||
export type FunLightboxPortalProps = Readonly<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export function FunLightboxPortal(props: FunLightboxPortalProps): JSX.Element {
|
||||
return createPortal(props.children, document.body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backdrop
|
||||
*/
|
||||
|
||||
export type FunLightboxBackdropProps = Readonly<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export function FunLightboxBackdrop(
|
||||
props: FunLightboxBackdropProps
|
||||
): JSX.Element {
|
||||
return <div className="FunLightbox__Backdrop">{props.children}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog
|
||||
*/
|
||||
|
||||
export type FunLightboxDialogProps = Readonly<{
|
||||
'aria-label': string;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export function FunLightboxDialog(props: FunLightboxDialogProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
className="FunLightbox__Dialog"
|
||||
aria-label={props['aria-label']}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,8 +1,11 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React, { useCallback } from 'react';
|
||||
import { VisuallyHidden } from 'react-aria';
|
||||
import type { LocalizerType } from '../../../types/I18N';
|
||||
|
||||
export type FunSearchProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
'aria-label': string;
|
||||
placeholder: string;
|
||||
searchInput: string;
|
||||
@@ -10,7 +13,8 @@ export type FunSearchProps = Readonly<{
|
||||
}>;
|
||||
|
||||
export function FunSearch(props: FunSearchProps): JSX.Element {
|
||||
const { onSearchInputChange } = props;
|
||||
const { i18n, onSearchInputChange } = props;
|
||||
|
||||
const handleChange = useCallback(
|
||||
event => {
|
||||
onSearchInputChange(event.target.value);
|
||||
@@ -18,6 +22,10 @@ export function FunSearch(props: FunSearchProps): JSX.Element {
|
||||
[onSearchInputChange]
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onSearchInputChange('');
|
||||
}, [onSearchInputChange]);
|
||||
|
||||
return (
|
||||
<div className="FunSearch__Container">
|
||||
<div className="FunSearch__Icon" />
|
||||
@@ -29,6 +37,19 @@ export function FunSearch(props: FunSearchProps): JSX.Element {
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
{props.searchInput !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className="FunSearch__Clear"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<span className="FunSearch__ClearButton">
|
||||
<VisuallyHidden>
|
||||
{i18n('icu:FunSearch__ClearButtonLabel')}
|
||||
</VisuallyHidden>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -279,8 +279,6 @@ export function FunSubNavImage(props: FunSubNavImageProps): JSX.Element {
|
||||
src={props.src}
|
||||
width={26}
|
||||
height={26}
|
||||
// presentational
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@ import React, { useCallback } from 'react';
|
||||
import type { Key } from 'react-aria';
|
||||
import { useId } from 'react-aria';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
|
||||
import type { FunPickerTabKey } from '../FunConstants';
|
||||
import type { FunPickerTabKey } from '../constants';
|
||||
|
||||
export type FunTabsProps = Readonly<{
|
||||
value: FunPickerTabKey;
|
||||
|
@@ -60,17 +60,3 @@ export const FunEmojisSectionOrder: ReadonlyArray<
|
||||
EmojiPickerCategory.Symbols,
|
||||
EmojiPickerCategory.Flags,
|
||||
];
|
||||
|
||||
export const FunGifsSectionOrder: ReadonlyArray<
|
||||
FunSectionCommon.Recents | FunGifsCategory
|
||||
> = [
|
||||
FunSectionCommon.Recents,
|
||||
FunGifsCategory.Trending,
|
||||
FunGifsCategory.Celebrate,
|
||||
FunGifsCategory.Love,
|
||||
FunGifsCategory.ThumbsUp,
|
||||
FunGifsCategory.Surprised,
|
||||
FunGifsCategory.Excited,
|
||||
FunGifsCategory.Sad,
|
||||
FunGifsCategory.Angry,
|
||||
];
|
@@ -52,8 +52,24 @@ export enum EmojiSkinTone {
|
||||
Type5 = 'EmojiSkinTone.Type5', // 1F3FF
|
||||
}
|
||||
|
||||
export function isValidEmojiSkinTone(value: unknown): value is EmojiSkinTone {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
EMOJI_SKIN_TONE_ORDER.includes(value as EmojiSkinTone)
|
||||
);
|
||||
}
|
||||
|
||||
export const EMOJI_SKIN_TONE_ORDER: ReadonlyArray<EmojiSkinTone> = [
|
||||
EmojiSkinTone.None,
|
||||
EmojiSkinTone.Type1,
|
||||
EmojiSkinTone.Type2,
|
||||
EmojiSkinTone.Type3,
|
||||
EmojiSkinTone.Type4,
|
||||
EmojiSkinTone.Type5,
|
||||
];
|
||||
|
||||
/** @deprecated We should use `EmojiSkinTone` everywhere */
|
||||
export const SKIN_TONE_TO_NUMBER: Map<EmojiSkinTone, number> = new Map([
|
||||
export const EMOJI_SKIN_TONE_TO_NUMBER: Map<EmojiSkinTone, number> = new Map([
|
||||
[EmojiSkinTone.None, 0],
|
||||
[EmojiSkinTone.Type1, 1],
|
||||
[EmojiSkinTone.Type2, 2],
|
||||
@@ -63,24 +79,22 @@ export const SKIN_TONE_TO_NUMBER: Map<EmojiSkinTone, number> = new Map([
|
||||
]);
|
||||
|
||||
/** @deprecated We should use `EmojiSkinTone` everywhere */
|
||||
export const NUMBER_TO_SKIN_TONE: Map<number, EmojiSkinTone> = new Map([
|
||||
[0, EmojiSkinTone.None],
|
||||
[1, EmojiSkinTone.Type1],
|
||||
[2, EmojiSkinTone.Type2],
|
||||
[3, EmojiSkinTone.Type3],
|
||||
[4, EmojiSkinTone.Type4],
|
||||
[5, EmojiSkinTone.Type5],
|
||||
export const KEY_TO_EMOJI_SKIN_TONE = new Map<string, EmojiSkinTone>([
|
||||
['1F3FB', EmojiSkinTone.Type1],
|
||||
['1F3FC', EmojiSkinTone.Type2],
|
||||
['1F3FD', EmojiSkinTone.Type3],
|
||||
['1F3FE', EmojiSkinTone.Type4],
|
||||
['1F3FF', EmojiSkinTone.Type5],
|
||||
]);
|
||||
|
||||
export type EmojiSkinToneVariant = Exclude<EmojiSkinTone, EmojiSkinTone.None>;
|
||||
|
||||
const KeyToEmojiSkinTone: Record<string, EmojiSkinToneVariant> = {
|
||||
'1F3FB': EmojiSkinTone.Type1,
|
||||
'1F3FC': EmojiSkinTone.Type2,
|
||||
'1F3FD': EmojiSkinTone.Type3,
|
||||
'1F3FE': EmojiSkinTone.Type4,
|
||||
'1F3FF': EmojiSkinTone.Type5,
|
||||
};
|
||||
/** @deprecated We should use `EmojiSkinTone` everywhere */
|
||||
export const EMOJI_SKIN_TONE_TO_KEY: Map<EmojiSkinTone, string> = new Map([
|
||||
[EmojiSkinTone.Type1, '1F3FB'],
|
||||
[EmojiSkinTone.Type2, '1F3FC'],
|
||||
[EmojiSkinTone.Type3, '1F3FD'],
|
||||
[EmojiSkinTone.Type4, '1F3FE'],
|
||||
[EmojiSkinTone.Type5, '1F3FF'],
|
||||
]);
|
||||
|
||||
export type EmojiParentKey = string & { EmojiParentKey: never };
|
||||
export type EmojiVariantKey = string & { EmojiVariantKey: never };
|
||||
@@ -94,18 +108,17 @@ export type EmojiEnglishShortName = string & { EmojiEnglishShortName: never };
|
||||
export type EmojiVariantData = Readonly<{
|
||||
key: EmojiVariantKey;
|
||||
value: EmojiVariantValue;
|
||||
valueNonqualified: EmojiVariantValue | null;
|
||||
sheetX: number;
|
||||
sheetY: number;
|
||||
}>;
|
||||
|
||||
type EmojiDefaultSkinToneVariants = Record<
|
||||
EmojiSkinToneVariant,
|
||||
EmojiVariantKey
|
||||
>;
|
||||
type EmojiDefaultSkinToneVariants = Record<EmojiSkinTone, EmojiVariantKey>;
|
||||
|
||||
export type EmojiParentData = Readonly<{
|
||||
key: EmojiParentKey;
|
||||
value: EmojiParentValue;
|
||||
valueNonqualified: EmojiParentValue | null;
|
||||
unicodeCategory: EmojiUnicodeCategory;
|
||||
pickerCategory: EmojiPickerCategory | null;
|
||||
defaultVariant: EmojiVariantKey;
|
||||
@@ -124,6 +137,7 @@ export type EmojiParentData = Readonly<{
|
||||
|
||||
const RawEmojiSkinToneSchema = z.object({
|
||||
unified: z.string(),
|
||||
non_qualified: z.union([z.string(), z.null()]),
|
||||
sheet_x: z.number(),
|
||||
sheet_y: z.number(),
|
||||
has_img_apple: z.boolean(),
|
||||
@@ -133,6 +147,7 @@ const RawEmojiSkinToneMapSchema = z.record(z.string(), RawEmojiSkinToneSchema);
|
||||
|
||||
const RawEmojiSchema = z.object({
|
||||
unified: z.string(),
|
||||
non_qualified: z.union([z.string(), z.null()]),
|
||||
category: z.string(),
|
||||
sort_order: z.number(),
|
||||
sheet_x: z.number(),
|
||||
@@ -282,6 +297,9 @@ const EMOJI_INDEX: EmojiIndex = {
|
||||
function addParent(parent: EmojiParentData, rank: number) {
|
||||
EMOJI_INDEX.parentByKey[parent.key] = parent;
|
||||
EMOJI_INDEX.parentKeysByValue[parent.value] = parent.key;
|
||||
if (parent.valueNonqualified != null) {
|
||||
EMOJI_INDEX.parentKeysByValue[parent.valueNonqualified] = parent.key;
|
||||
}
|
||||
EMOJI_INDEX.parentKeysByName[parent.englishShortNameDefault] = parent.key;
|
||||
EMOJI_INDEX.unicodeCategories[parent.unicodeCategory].push(parent.key);
|
||||
if (parent.pickerCategory != null) {
|
||||
@@ -306,6 +324,9 @@ function addVariant(parentKey: EmojiParentKey, variant: EmojiVariantData) {
|
||||
EMOJI_INDEX.parentKeysByVariantKeys[variant.key] = parentKey;
|
||||
EMOJI_INDEX.variantByKey[variant.key] = variant;
|
||||
EMOJI_INDEX.variantKeysByValue[variant.value] = variant.key;
|
||||
if (variant.valueNonqualified) {
|
||||
EMOJI_INDEX.variantKeysByValue[variant.valueNonqualified] = variant.key;
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawEmoji of RAW_EMOJI_DATA) {
|
||||
@@ -314,6 +335,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
|
||||
const defaultVariant: EmojiVariantData = {
|
||||
key: toEmojiVariantKey(rawEmoji.unified),
|
||||
value: toEmojiVariantValue(rawEmoji.unified),
|
||||
valueNonqualified:
|
||||
rawEmoji.non_qualified != null
|
||||
? toEmojiVariantValue(rawEmoji.non_qualified)
|
||||
: null,
|
||||
sheetX: rawEmoji.sheet_x,
|
||||
sheetY: rawEmoji.sheet_y,
|
||||
};
|
||||
@@ -331,6 +356,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
|
||||
const skinToneVariant: EmojiVariantData = {
|
||||
key: variantKey,
|
||||
value: toEmojiVariantValue(value.unified),
|
||||
valueNonqualified:
|
||||
rawEmoji.non_qualified != null
|
||||
? toEmojiVariantValue(rawEmoji.non_qualified)
|
||||
: null,
|
||||
sheetX: value.sheet_x,
|
||||
sheetY: value.sheet_y,
|
||||
};
|
||||
@@ -339,7 +368,7 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
|
||||
}
|
||||
|
||||
const result: Partial<EmojiDefaultSkinToneVariants> = {};
|
||||
for (const [key, skinTone] of Object.entries(KeyToEmojiSkinTone)) {
|
||||
for (const [key, skinTone] of KEY_TO_EMOJI_SKIN_TONE) {
|
||||
const one = map.get(key) ?? null;
|
||||
const two = map.get(`${key}-${key}`) ?? null;
|
||||
const variantKey = one ?? two;
|
||||
@@ -356,6 +385,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
|
||||
const parent: EmojiParentData = {
|
||||
key: toEmojiParentKey(rawEmoji.unified),
|
||||
value: toEmojiParentValue(rawEmoji.unified),
|
||||
valueNonqualified:
|
||||
rawEmoji.non_qualified != null
|
||||
? toEmojiParentValue(rawEmoji.non_qualified)
|
||||
: null,
|
||||
unicodeCategory: toEmojiUnicodeCategory(rawEmoji.category),
|
||||
pickerCategory: toEmojiPickerCategory(rawEmoji.category),
|
||||
defaultVariant: defaultVariant.key,
|
||||
@@ -404,16 +437,6 @@ export function getEmojiVariantByKey(key: EmojiVariantKey): EmojiVariantData {
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getEmojiParentKeyByValueUnsafe(input: string): EmojiParentKey {
|
||||
strictAssert(
|
||||
isEmojiParentValue(input),
|
||||
`Missing emoji parent value for input "${input}"`
|
||||
);
|
||||
const key = EMOJI_INDEX.parentKeysByValue[input];
|
||||
strictAssert(key, `Missing emoji parent key for input "${input}"`);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function getEmojiParentKeyByValue(
|
||||
value: EmojiParentValue
|
||||
): EmojiParentKey {
|
||||
@@ -492,6 +515,14 @@ export function* _allEmojiVariantKeys(): Iterable<EmojiVariantKey> {
|
||||
yield* Object.keys(EMOJI_INDEX.variantByKey) as Array<EmojiVariantKey>;
|
||||
}
|
||||
|
||||
export function emojiParentKeyConstant(input: string): EmojiParentKey {
|
||||
strictAssert(
|
||||
isEmojiParentValue(input),
|
||||
`Missing emoji parent for value "${input}"`
|
||||
);
|
||||
return getEmojiParentKeyByValue(input);
|
||||
}
|
||||
|
||||
export function emojiVariantConstant(input: string): EmojiVariantData {
|
||||
strictAssert(
|
||||
isEmojiVariantValue(input),
|
||||
|
@@ -10,7 +10,7 @@ import type {
|
||||
} from './tenor';
|
||||
import { tenor, isTenorTailCursor } from './tenor';
|
||||
|
||||
const PREVIEW_CONTENT_FORMAT: TenorContentFormat = 'mediumgif';
|
||||
const PREVIEW_CONTENT_FORMAT: TenorContentFormat = 'tinymp4';
|
||||
const ATTACHMENT_CONTENT_FORMAT: TenorContentFormat = 'mp4';
|
||||
|
||||
function toGif(result: TenorResponseResult): GifType {
|
||||
@@ -40,7 +40,7 @@ export type GifsPaginated = Readonly<{
|
||||
gifs: ReadonlyArray<GifType>;
|
||||
}>;
|
||||
|
||||
export async function fetchFeatured(
|
||||
export async function fetchGifsFeatured(
|
||||
limit: number,
|
||||
cursor: TenorNextCursor | null,
|
||||
signal?: AbortSignal
|
||||
@@ -48,7 +48,7 @@ export async function fetchFeatured(
|
||||
const response = await tenor(
|
||||
'v2/featured',
|
||||
{
|
||||
// contentfilter: 'medium',
|
||||
contentfilter: 'low',
|
||||
media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
|
||||
limit,
|
||||
pos: cursor ?? undefined,
|
||||
@@ -61,7 +61,7 @@ export async function fetchFeatured(
|
||||
return { next, gifs };
|
||||
}
|
||||
|
||||
export async function fetchSearch(
|
||||
export async function fetchGifsSearch(
|
||||
query: string,
|
||||
limit: number,
|
||||
cursor: TenorNextCursor | null,
|
||||
@@ -71,7 +71,7 @@ export async function fetchSearch(
|
||||
'v2/search',
|
||||
{
|
||||
q: query,
|
||||
contentfilter: 'medium',
|
||||
contentfilter: 'low',
|
||||
media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
|
||||
limit,
|
||||
pos: cursor ?? undefined,
|
||||
|
@@ -52,7 +52,7 @@ export function useInfiniteQuery<Query, Page>(
|
||||
const [edition, setEdition] = useState(0);
|
||||
const [state, setState] = useState<InfiniteQueryState<Query, Page>>({
|
||||
query: options.query,
|
||||
pending: false,
|
||||
pending: true,
|
||||
rejected: false,
|
||||
pages: [],
|
||||
hasNextPage: false,
|
||||
|
132
ts/components/fun/data/segments.ts
Normal file
132
ts/components/fun/data/segments.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export const _SEGMENT_SIZE_BUCKETS: ReadonlyArray<number> = [
|
||||
// highest to lowest
|
||||
1024 * 1024, // 1MiB
|
||||
1024 * 500, // 500 KiB
|
||||
1024 * 100, // 100 KiB
|
||||
1024 * 50, // 50 KiB
|
||||
1024 * 10, // 10 KiB
|
||||
1024 * 1, // 1 KiB
|
||||
];
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export type _SegmentRange = Readonly<{
|
||||
startIndex: number;
|
||||
endIndexInclusive: number;
|
||||
sliceStart: number;
|
||||
segmentSize: number;
|
||||
sliceSize: number;
|
||||
}>;
|
||||
|
||||
async function fetchContentLength(
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<number> {
|
||||
const { messaging } = window.textsecure;
|
||||
strictAssert(messaging, 'Missing window.textsecure.messaging');
|
||||
const { response } = await messaging.server.fetchBytesViaProxy({
|
||||
url,
|
||||
method: 'HEAD',
|
||||
signal,
|
||||
});
|
||||
const contentLength = Number(response.headers.get('Content-Length'));
|
||||
strictAssert(
|
||||
Number.isInteger(contentLength),
|
||||
'Content-Length must be integer'
|
||||
);
|
||||
return contentLength;
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function _getSegmentSize(contentLength: number): number {
|
||||
const nextLargestSegmentSize = _SEGMENT_SIZE_BUCKETS.find(segmentSize => {
|
||||
return contentLength >= segmentSize;
|
||||
});
|
||||
// If too small, return the content length
|
||||
return nextLargestSegmentSize ?? contentLength;
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function _getSegmentRanges(
|
||||
contentLength: number,
|
||||
segmentSize: number
|
||||
): ReadonlyArray<_SegmentRange> {
|
||||
const segmentRanges: Array<_SegmentRange> = [];
|
||||
const segmentCount = Math.ceil(contentLength / segmentSize);
|
||||
for (let index = 0; index < segmentCount; index += 1) {
|
||||
let startIndex = segmentSize * index;
|
||||
let endIndexInclusive = startIndex + segmentSize - 1;
|
||||
let sliceSize = segmentSize;
|
||||
let sliceStart = 0;
|
||||
|
||||
if (endIndexInclusive > contentLength) {
|
||||
endIndexInclusive = contentLength - 1;
|
||||
startIndex = contentLength - segmentSize;
|
||||
sliceSize = contentLength % segmentSize;
|
||||
sliceStart = segmentSize - sliceSize;
|
||||
}
|
||||
|
||||
segmentRanges.push({
|
||||
startIndex,
|
||||
endIndexInclusive,
|
||||
sliceStart,
|
||||
segmentSize,
|
||||
sliceSize,
|
||||
});
|
||||
}
|
||||
return segmentRanges;
|
||||
}
|
||||
|
||||
async function fetchSegment(
|
||||
url: string,
|
||||
segmentRange: _SegmentRange,
|
||||
signal?: AbortSignal
|
||||
): Promise<ArrayBufferView> {
|
||||
const { messaging } = window.textsecure;
|
||||
strictAssert(messaging, 'Missing window.textsecure.messaging');
|
||||
const { data } = await messaging.server.fetchBytesViaProxy({
|
||||
method: 'GET',
|
||||
url,
|
||||
signal,
|
||||
headers: {
|
||||
Range: `bytes=${segmentRange.startIndex}-${segmentRange.endIndexInclusive}`,
|
||||
},
|
||||
});
|
||||
|
||||
strictAssert(
|
||||
data.buffer.byteLength === segmentRange.segmentSize,
|
||||
'Response buffer should be exact length of segment range'
|
||||
);
|
||||
|
||||
let slice: ArrayBufferView;
|
||||
// Trim duplicate bytes from start of last segment
|
||||
if (segmentRange.sliceStart > 0) {
|
||||
slice = new Uint8Array(data.buffer.slice(segmentRange.sliceStart));
|
||||
} else {
|
||||
slice = data;
|
||||
}
|
||||
strictAssert(
|
||||
slice.byteLength === segmentRange.sliceSize,
|
||||
'Slice buffer should be exact length of segment range slice'
|
||||
);
|
||||
return slice;
|
||||
}
|
||||
|
||||
export async function fetchInSegments(
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Blob> {
|
||||
const contentLength = await fetchContentLength(url, signal);
|
||||
const segmentSize = _getSegmentSize(contentLength);
|
||||
const segmentRanges = _getSegmentRanges(contentLength, segmentSize);
|
||||
const segmentBuffers = await Promise.all(
|
||||
segmentRanges.map(segmentRange => {
|
||||
return fetchSegment(url, segmentRange, signal);
|
||||
})
|
||||
);
|
||||
return new Blob(segmentBuffers);
|
||||
}
|
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
import type { Simplify } from 'type-fest';
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
import { parseUnknown } from '../../../util/schemas';
|
||||
import { fetchInSegments } from './segments';
|
||||
|
||||
const BASE_URL = 'https://tenor.googleapis.com/v2';
|
||||
const API_KEY = 'AIzaSyBt6SUfSsCQic2P2VkNkLjsGI7HGWZI95g';
|
||||
@@ -215,23 +216,18 @@ export async function tenor<Path extends keyof TenorEndpoints>(
|
||||
url.searchParams.set(key, param);
|
||||
}
|
||||
|
||||
const response = await messaging.server.fetchJsonViaProxy(
|
||||
url.toString(),
|
||||
signal
|
||||
);
|
||||
const response = await messaging.server.fetchJsonViaProxy({
|
||||
method: 'GET',
|
||||
url: url.toString(),
|
||||
signal,
|
||||
});
|
||||
const result = parseUnknown(schema, response.data);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function tenorDownload(
|
||||
export function tenorDownload(
|
||||
tenorCdnUrl: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Uint8Array> {
|
||||
const { messaging } = window.textsecure;
|
||||
strictAssert(messaging, 'Missing window.textsecure.messaging');
|
||||
const response = await messaging.server.fetchBytesViaProxy(
|
||||
tenorCdnUrl,
|
||||
signal
|
||||
);
|
||||
return response.data;
|
||||
): Promise<Blob> {
|
||||
return fetchInSegments(tenorCdnUrl, signal);
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ import {
|
||||
getEmojiParentKeyByEnglishShortName,
|
||||
isEmojiEnglishShortName,
|
||||
} from './data/emojis';
|
||||
import type { GifsPaginated } from './data/gifs';
|
||||
|
||||
function getEmoji(input: string): EmojiParentKey {
|
||||
strictAssert(
|
||||
@@ -50,3 +51,29 @@ export const MOCK_RECENT_EMOJIS: ReadonlyArray<EmojiParentKey> = [
|
||||
getEmoji('open_mouth'),
|
||||
getEmoji('zipper_mouth_face'),
|
||||
];
|
||||
|
||||
export const MOCK_GIFS_PAGINATED_EMPTY: GifsPaginated = {
|
||||
next: null,
|
||||
gifs: [],
|
||||
};
|
||||
|
||||
export const MOCK_GIFS_PAGINATED_ONE_PAGE: GifsPaginated = {
|
||||
next: null,
|
||||
gifs: Array.from({ length: 30 }, (_, i) => {
|
||||
return {
|
||||
id: String(i),
|
||||
title: '',
|
||||
description: '',
|
||||
previewMedia: {
|
||||
url: 'https://media.tenor.com/ihqN6a3iiYEAAAPo/pikachu-shocked-face-stunned.mp4',
|
||||
width: 640,
|
||||
height: 640,
|
||||
},
|
||||
attachmentMedia: {
|
||||
url: 'https://media.tenor.com/ihqN6a3iiYEAAAPo/pikachu-shocked-face-stunned.mp4',
|
||||
width: 640,
|
||||
height: 640,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
@@ -6,8 +6,8 @@ import { DialogTrigger } from 'react-aria-components';
|
||||
import type { LocalizerType } from '../../../types/I18N';
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
import { missingCaseError } from '../../../util/missingCaseError';
|
||||
import type { FunEmojisSection } from '../FunConstants';
|
||||
import { FunEmojisSectionOrder, FunSectionCommon } from '../FunConstants';
|
||||
import type { FunEmojisSection } from '../constants';
|
||||
import { FunEmojisSectionOrder, FunSectionCommon } from '../constants';
|
||||
import {
|
||||
FunGridCell,
|
||||
FunGridContainer,
|
||||
@@ -37,10 +37,10 @@ import type {
|
||||
EmojiVariantKey,
|
||||
} from '../data/emojis';
|
||||
import {
|
||||
emojiParentKeyConstant,
|
||||
EmojiPickerCategory,
|
||||
emojiVariantConstant,
|
||||
getEmojiParentByKey,
|
||||
getEmojiParentKeyByValueUnsafe,
|
||||
getEmojiPickerCategoryParentKeys,
|
||||
getEmojiVariantByParentKeyAndSkinTone,
|
||||
isEmojiParentKey,
|
||||
@@ -55,8 +55,8 @@ import type {
|
||||
GridSectionNode,
|
||||
} from '../virtual/useFunVirtualGrid';
|
||||
import { useFunVirtualGrid } from '../virtual/useFunVirtualGrid';
|
||||
import { SkinTonesListBox } from '../base/FunSkinTones';
|
||||
import { FunEmoji } from '../FunEmoji';
|
||||
import { FunSkinTonesList } from '../FunSkinTones';
|
||||
import { FunStaticEmoji } from '../FunEmoji';
|
||||
import { useFunContext } from '../FunProvider';
|
||||
import { FunResults, FunResultsHeader } from '../base/FunResults';
|
||||
|
||||
@@ -252,6 +252,7 @@ export function FunPanelEmojis({
|
||||
return (
|
||||
<FunPanel>
|
||||
<FunSearch
|
||||
i18n={i18n}
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={onSearchInputChange}
|
||||
placeholder={i18n('icu:FunPanelEmojis__SearchLabel')}
|
||||
@@ -342,11 +343,9 @@ export function FunPanelEmojis({
|
||||
<FunResults aria-busy={false}>
|
||||
<FunResultsHeader>
|
||||
{i18n('icu:FunPanelEmojis__SearchResults__EmptyHeading')}{' '}
|
||||
<FunEmoji
|
||||
<FunStaticEmoji
|
||||
size={16}
|
||||
role="presentation"
|
||||
// For presentation only
|
||||
aria-label=""
|
||||
emoji={emojiVariantConstant('\u{1F641}')}
|
||||
/>
|
||||
</FunResultsHeader>
|
||||
@@ -386,8 +385,8 @@ export function FunPanelEmojis({
|
||||
{section.id === EmojiPickerCategory.SmileysAndPeople && (
|
||||
<SectionSkinTonePopover
|
||||
i18n={i18n}
|
||||
skinTone={fun.defaultEmojiSkinTone}
|
||||
onSelectSkinTone={fun.onChangeDefaultEmojiSkinTone}
|
||||
skinTone={fun.emojiSkinToneDefault}
|
||||
onSelectSkinTone={fun.onEmojiSkinToneDefaultChange}
|
||||
/>
|
||||
)}
|
||||
</FunGridHeader>
|
||||
@@ -405,7 +404,7 @@ export function FunPanelEmojis({
|
||||
rowIndex={row.rowIndex}
|
||||
cells={row.cells}
|
||||
focusedCellKey={focusedCellKey}
|
||||
defaultEmojiSkinTone={fun.defaultEmojiSkinTone}
|
||||
emojiSkinToneDefault={fun.emojiSkinToneDefault}
|
||||
onPressEmoji={handlePressEmoji}
|
||||
/>
|
||||
);
|
||||
@@ -426,7 +425,7 @@ type RowProps = Readonly<{
|
||||
rowIndex: number;
|
||||
cells: ReadonlyArray<CellLayoutNode>;
|
||||
focusedCellKey: CellKey | null;
|
||||
defaultEmojiSkinTone: EmojiSkinTone;
|
||||
emojiSkinToneDefault: EmojiSkinTone;
|
||||
onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void;
|
||||
}>;
|
||||
|
||||
@@ -446,7 +445,7 @@ const Row = memo(function Row(props: RowProps): JSX.Element {
|
||||
rowIndex={cell.rowIndex}
|
||||
colIndex={cell.colIndex}
|
||||
isTabbable={isTabbable}
|
||||
defaultEmojiSkinTone={props.defaultEmojiSkinTone}
|
||||
emojiSkinToneDefault={props.emojiSkinToneDefault}
|
||||
onPressEmoji={props.onPressEmoji}
|
||||
/>
|
||||
);
|
||||
@@ -461,7 +460,7 @@ type CellProps = Readonly<{
|
||||
colIndex: number;
|
||||
rowIndex: number;
|
||||
isTabbable: boolean;
|
||||
defaultEmojiSkinTone: EmojiSkinTone;
|
||||
emojiSkinToneDefault: EmojiSkinTone;
|
||||
onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void;
|
||||
}>;
|
||||
|
||||
@@ -478,8 +477,8 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
|
||||
|
||||
const skinTone = useMemo(() => {
|
||||
// TODO(jamie): Need to implement emoji-specific skin tone preferences
|
||||
return props.defaultEmojiSkinTone;
|
||||
}, [props.defaultEmojiSkinTone]);
|
||||
return props.emojiSkinToneDefault;
|
||||
}, [props.emojiSkinToneDefault]);
|
||||
|
||||
const emojiVariant = useMemo(() => {
|
||||
return getEmojiVariantByParentKeyAndSkinTone(emojiParent.key, skinTone);
|
||||
@@ -509,12 +508,7 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
|
||||
aria-label={emojiParent.englishShortNameDefault}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<FunEmoji
|
||||
role="presentation"
|
||||
aria-label=""
|
||||
size={32}
|
||||
emoji={emojiVariant}
|
||||
/>
|
||||
<FunStaticEmoji role="presentation" size={32} emoji={emojiVariant} />
|
||||
</FunItemButton>
|
||||
</FunGridCell>
|
||||
);
|
||||
@@ -555,8 +549,9 @@ function SectionSkinTonePopover(
|
||||
<FunGridHeaderPopoverHeader>
|
||||
{i18n('icu:FunPanelEmojis__SkinTonePicker__ChooseDefaultLabel')}
|
||||
</FunGridHeaderPopoverHeader>
|
||||
<SkinTonesListBox
|
||||
emoji={getEmojiParentKeyByValueUnsafe('\u{270B}')}
|
||||
<FunSkinTonesList
|
||||
i18n={i18n}
|
||||
emoji={emojiParentKeyConstant('\u{270B}')}
|
||||
skinTone={props.skinTone}
|
||||
onSelectSkinTone={handleSelectSkinTone}
|
||||
/>
|
||||
|
@@ -11,9 +11,9 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { VisuallyHidden } from 'react-aria';
|
||||
import { useId, VisuallyHidden } from 'react-aria';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { FunItemButton, FunItemGif } from '../base/FunItem';
|
||||
import { FunItemButton } from '../base/FunItem';
|
||||
import { FunPanel } from '../base/FunPanel';
|
||||
import { FunScroller } from '../base/FunScroller';
|
||||
import { FunSearch } from '../base/FunSearch';
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
FunSubNavListBoxItem,
|
||||
} from '../base/FunSubNav';
|
||||
import { FunWaterfallContainer, FunWaterfallItem } from '../base/FunWaterfall';
|
||||
import type { FunGifsSection } from '../FunConstants';
|
||||
import { FunGifsCategory, FunSectionCommon } from '../FunConstants';
|
||||
import type { FunGifsSection } from '../constants';
|
||||
import { FunGifsCategory, FunSectionCommon } from '../constants';
|
||||
import { FunKeyboard } from '../keyboard/FunKeyboard';
|
||||
import type { WaterfallKeyboardState } from '../keyboard/WaterfallKeyboardDelegate';
|
||||
import { WaterfallKeyboardDelegate } from '../keyboard/WaterfallKeyboardDelegate';
|
||||
@@ -33,9 +33,7 @@ import { useInfiniteQuery } from '../data/infinite';
|
||||
import { missingCaseError } from '../../../util/missingCaseError';
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
import type { GifsPaginated } from '../data/gifs';
|
||||
import { fetchFeatured, fetchSearch } from '../data/gifs';
|
||||
import { drop } from '../../../util/drop';
|
||||
import { tenorDownload } from '../data/tenor';
|
||||
import { useFunContext } from '../FunProvider';
|
||||
import {
|
||||
FunResults,
|
||||
@@ -44,8 +42,21 @@ import {
|
||||
FunResultsHeader,
|
||||
FunResultsSpinner,
|
||||
} from '../base/FunResults';
|
||||
import { FunEmoji } from '../FunEmoji';
|
||||
import { FunStaticEmoji } from '../FunEmoji';
|
||||
import { emojiVariantConstant } from '../data/emojis';
|
||||
import {
|
||||
FunLightboxPortal,
|
||||
FunLightboxBackdrop,
|
||||
FunLightboxDialog,
|
||||
FunLightboxProvider,
|
||||
useFunLightboxKey,
|
||||
} from '../base/FunLightbox';
|
||||
import type { tenorDownload } from '../data/tenor';
|
||||
import { FunGif } from '../FunGif';
|
||||
import type { LocalizerType } from '../../../types/I18N';
|
||||
import { isAbortError } from '../../../util/isAbortError';
|
||||
import * as log from '../../../logging/log';
|
||||
import * as Errors from '../../../types/errors';
|
||||
|
||||
const MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
const FunGifBlobCache = new LRUCache<string, Blob>({
|
||||
@@ -95,7 +106,12 @@ type GifsQuery = Readonly<{
|
||||
}>;
|
||||
|
||||
export type FunGifSelection = Readonly<{
|
||||
attachmentMedia: GifMediaType;
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
|
||||
export type FunPanelGifsProps = Readonly<{
|
||||
@@ -115,6 +131,9 @@ export function FunPanelGifs({
|
||||
selectedGifsSection,
|
||||
onChangeSelectedSelectGifsSection,
|
||||
recentGifs,
|
||||
fetchGifsFeatured,
|
||||
fetchGifsSearch,
|
||||
fetchGif,
|
||||
} = fun;
|
||||
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -142,6 +161,14 @@ export function FunPanelGifs({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
debouncedQuery.searchQuery === searchQuery &&
|
||||
debouncedQuery.selectedSection === selectedGifsSection
|
||||
) {
|
||||
// don't update twice
|
||||
return;
|
||||
}
|
||||
|
||||
const query: GifsQuery = {
|
||||
selectedSection: selectedGifsSection,
|
||||
searchQuery,
|
||||
@@ -158,7 +185,7 @@ export function FunPanelGifs({
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [searchQuery, selectedGifsSection]);
|
||||
}, [debouncedQuery, searchQuery, selectedGifsSection]);
|
||||
|
||||
const loader = useCallback(
|
||||
async (
|
||||
@@ -170,7 +197,7 @@ export function FunPanelGifs({
|
||||
const limit = cursor != null ? 30 : 10;
|
||||
|
||||
if (query.searchQuery !== '') {
|
||||
return fetchSearch(query.searchQuery, limit, cursor, signal);
|
||||
return fetchGifsSearch(query.searchQuery, limit, cursor, signal);
|
||||
}
|
||||
strictAssert(
|
||||
query.selectedSection !== FunSectionCommon.SearchResults,
|
||||
@@ -180,33 +207,33 @@ export function FunPanelGifs({
|
||||
return { next: null, gifs: recentGifs };
|
||||
}
|
||||
if (query.selectedSection === FunGifsCategory.Trending) {
|
||||
return fetchFeatured(limit, cursor, signal);
|
||||
return fetchGifsFeatured(limit, cursor, signal);
|
||||
}
|
||||
if (query.selectedSection === FunGifsCategory.Celebrate) {
|
||||
return fetchSearch('celebrate', limit, cursor, signal);
|
||||
return fetchGifsSearch('celebrate', limit, cursor, signal);
|
||||
}
|
||||
if (query.selectedSection === FunGifsCategory.Love) {
|
||||
return fetchSearch('love', limit, cursor, signal);
|
||||
return fetchGifsSearch('love', limit, cursor, signal);
|
||||
}
|
||||
if (query.selectedSection === FunGifsCategory.ThumbsUp) {
|
||||
return fetchSearch('thumbs-up', limit, cursor, signal);
|
||||
return fetchGifsSearch('thumbs-up', limit, cursor, signal);
|
||||
}
|
||||
if (query.selectedSection === FunGifsCategory.Surprised) {
|
||||
return fetchSearch('surprised', limit, cursor, signal);
|
||||
return fetchGifsSearch('surprised', limit, cursor, signal);
|
||||
}
|
||||
if (query.selectedSection === FunGifsCategory.Excited) {
|
||||
return fetchSearch('excited', limit, cursor, signal);
|
||||
return fetchGifsSearch('excited', limit, cursor, signal);
|
||||
}
|
||||
if (query.selectedSection === FunGifsCategory.Sad) {
|
||||
return fetchSearch('sad', limit, cursor, signal);
|
||||
return fetchGifsSearch('sad', limit, cursor, signal);
|
||||
}
|
||||
if (query.selectedSection === FunGifsCategory.Angry) {
|
||||
return fetchSearch('angry', limit, cursor, signal);
|
||||
return fetchGifsSearch('angry', limit, cursor, signal);
|
||||
}
|
||||
|
||||
throw missingCaseError(query.selectedSection);
|
||||
},
|
||||
[recentGifs]
|
||||
[recentGifs, fetchGifsSearch, fetchGifsFeatured]
|
||||
);
|
||||
|
||||
const hasNextPage = useCallback(
|
||||
@@ -275,6 +302,7 @@ export function FunPanelGifs({
|
||||
scrollPaddingStart: 20,
|
||||
scrollPaddingEnd: 20,
|
||||
getItemKey,
|
||||
initialOffset: 100,
|
||||
});
|
||||
|
||||
// Scroll back to top when query changes
|
||||
@@ -350,6 +378,7 @@ export function FunPanelGifs({
|
||||
return (
|
||||
<FunPanel>
|
||||
<FunSearch
|
||||
i18n={i18n}
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={handleSearchInputChange}
|
||||
placeholder={i18n('icu:FunPanelGifs__SearchPlaceholder--Tenor')}
|
||||
@@ -449,11 +478,9 @@ export function FunPanelGifs({
|
||||
{!queryState.pending && !queryState.rejected && (
|
||||
<FunResultsHeader>
|
||||
{i18n('icu:FunPanelGifs__SearchResults__EmptyHeading')}{' '}
|
||||
<FunEmoji
|
||||
<FunStaticEmoji
|
||||
size={16}
|
||||
role="presentation"
|
||||
// For presentation only
|
||||
aria-label=""
|
||||
emoji={emojiVariantConstant('\u{1F641}')}
|
||||
/>
|
||||
</FunResultsHeader>
|
||||
@@ -461,6 +488,8 @@ export function FunPanelGifs({
|
||||
</FunResults>
|
||||
)}
|
||||
{count !== 0 && (
|
||||
<FunLightboxProvider containerRef={scrollerRef}>
|
||||
<GifsLightbox i18n={i18n} items={items} />
|
||||
<FunKeyboard
|
||||
scrollerRef={scrollerRef}
|
||||
keyboard={keyboard}
|
||||
@@ -484,11 +513,13 @@ export function FunPanelGifs({
|
||||
itemLane={item.lane}
|
||||
isTabbable={isTabbable}
|
||||
onPressGif={handlePressGif}
|
||||
fetchGif={fetchGif}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</FunWaterfallContainer>
|
||||
</FunKeyboard>
|
||||
</FunLightboxProvider>
|
||||
)}
|
||||
</FunScroller>
|
||||
</FunPanel>
|
||||
@@ -503,16 +534,24 @@ const Item = memo(function Item(props: {
|
||||
itemLane: number;
|
||||
isTabbable: boolean;
|
||||
onPressGif: (event: MouseEvent, gifSelection: FunGifSelection) => void;
|
||||
fetchGif: typeof tenorDownload;
|
||||
}) {
|
||||
const { onPressGif } = props;
|
||||
const { onPressGif, fetchGif } = props;
|
||||
|
||||
const handleClick = useCallback(
|
||||
async (event: MouseEvent) => {
|
||||
onPressGif(event, {
|
||||
attachmentMedia: props.gif.attachmentMedia,
|
||||
id: props.gif.id,
|
||||
title: props.gif.title,
|
||||
description: props.gif.description,
|
||||
url: props.gif.attachmentMedia.url,
|
||||
width: props.gif.attachmentMedia.width,
|
||||
height: props.gif.attachmentMedia.height,
|
||||
});
|
||||
},
|
||||
[props.gif, onPressGif]
|
||||
);
|
||||
|
||||
const descriptionId = `FunGifsPanelItem__GifDescription--${props.gif.id}`;
|
||||
const [src, setSrc] = useState<string | null>(() => {
|
||||
const cached = readGifMediaFromCache(props.gif.previewMedia);
|
||||
@@ -528,10 +567,16 @@ const Item = memo(function Item(props: {
|
||||
const { signal } = controller;
|
||||
|
||||
async function download() {
|
||||
const bytes = await tenorDownload(props.gif.previewMedia.url, signal);
|
||||
try {
|
||||
const bytes = await fetchGif(props.gif.previewMedia.url, signal);
|
||||
const blob = new Blob([bytes]);
|
||||
saveGifMediaToCache(props.gif.previewMedia, blob);
|
||||
setSrc(URL.createObjectURL(blob));
|
||||
} catch (error) {
|
||||
if (!isAbortError(error)) {
|
||||
log.error('Failed to download gif', Errors.toLogFormat(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(download());
|
||||
@@ -539,7 +584,7 @@ const Item = memo(function Item(props: {
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [props.gif, src]);
|
||||
}, [props.gif, src, fetchGif]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -559,15 +604,15 @@ const Item = memo(function Item(props: {
|
||||
>
|
||||
<FunItemButton
|
||||
aria-label={props.gif.title}
|
||||
aria-describedby={descriptionId}
|
||||
onClick={handleClick}
|
||||
tabIndex={props.isTabbable ? 0 : -1}
|
||||
>
|
||||
{src != null && (
|
||||
<FunItemGif
|
||||
<FunGif
|
||||
src={src}
|
||||
width={props.gif.previewMedia.width}
|
||||
height={props.gif.previewMedia.height}
|
||||
aria-describedby={descriptionId}
|
||||
/>
|
||||
)}
|
||||
<VisuallyHidden id={descriptionId}>
|
||||
@@ -577,3 +622,59 @@ const Item = memo(function Item(props: {
|
||||
</FunWaterfallItem>
|
||||
);
|
||||
});
|
||||
|
||||
function GifsLightbox(props: {
|
||||
i18n: LocalizerType;
|
||||
items: ReadonlyArray<GifType>;
|
||||
}) {
|
||||
const { i18n } = props;
|
||||
const key = useFunLightboxKey();
|
||||
const descriptionId = useId();
|
||||
|
||||
const result = useMemo(() => {
|
||||
if (key == null) {
|
||||
return null;
|
||||
}
|
||||
const gif = props.items.find(item => {
|
||||
return item.id === key;
|
||||
});
|
||||
strictAssert(gif, `Must have gif for "${key}"`);
|
||||
const blob = readGifMediaFromCache(gif.previewMedia);
|
||||
strictAssert(blob, 'Missing media');
|
||||
const url = URL.createObjectURL(blob);
|
||||
return { gif, url };
|
||||
}, [props.items, key]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (result != null) {
|
||||
URL.revokeObjectURL(result.url);
|
||||
}
|
||||
};
|
||||
}, [result]);
|
||||
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FunLightboxPortal>
|
||||
<FunLightboxBackdrop>
|
||||
<FunLightboxDialog
|
||||
aria-label={i18n('icu:FunPanelGifs__LightboxDialog__Label')}
|
||||
>
|
||||
<FunGif
|
||||
src={result.url}
|
||||
width={result.gif.previewMedia.width}
|
||||
height={result.gif.previewMedia.height}
|
||||
aria-describedby={descriptionId}
|
||||
ignoreReducedMotion
|
||||
/>
|
||||
<VisuallyHidden id={descriptionId}>
|
||||
{result.gif.description}
|
||||
</VisuallyHidden>
|
||||
</FunLightboxDialog>
|
||||
</FunLightboxBackdrop>
|
||||
</FunLightboxPortal>
|
||||
);
|
||||
}
|
||||
|
@@ -8,12 +8,12 @@ import type {
|
||||
} from '../../../state/ducks/stickers';
|
||||
import type { LocalizerType } from '../../../types/I18N';
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
import type { FunStickersSection } from '../FunConstants';
|
||||
import type { FunStickersSection } from '../constants';
|
||||
import {
|
||||
FunSectionCommon,
|
||||
FunStickersSectionBase,
|
||||
toFunStickersPackSection,
|
||||
} from '../FunConstants';
|
||||
} from '../constants';
|
||||
import {
|
||||
FunGridCell,
|
||||
FunGridContainer,
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
FunGridRowGroup,
|
||||
FunGridScrollerSection,
|
||||
} from '../base/FunGrid';
|
||||
import { FunItemButton, FunItemSticker } from '../base/FunItem';
|
||||
import { FunItemButton } from '../base/FunItem';
|
||||
import { FunPanel } from '../base/FunPanel';
|
||||
import { FunScroller } from '../base/FunScroller';
|
||||
import { FunSearch } from '../base/FunSearch';
|
||||
@@ -54,7 +54,15 @@ import type {
|
||||
import { useFunVirtualGrid } from '../virtual/useFunVirtualGrid';
|
||||
import { useFunContext } from '../FunProvider';
|
||||
import { FunResults, FunResultsHeader } from '../base/FunResults';
|
||||
import { FunEmoji } from '../FunEmoji';
|
||||
import { FunStaticEmoji } from '../FunEmoji';
|
||||
import {
|
||||
FunLightboxPortal,
|
||||
FunLightboxBackdrop,
|
||||
FunLightboxDialog,
|
||||
FunLightboxProvider,
|
||||
useFunLightboxKey,
|
||||
} from '../base/FunLightbox';
|
||||
import { FunSticker } from '../FunSticker';
|
||||
|
||||
const STICKER_GRID_COLUMNS = 4;
|
||||
const STICKER_GRID_CELL_WIDTH = 80;
|
||||
@@ -267,6 +275,7 @@ export function FunPanelStickers({
|
||||
return (
|
||||
<FunPanel>
|
||||
<FunSearch
|
||||
i18n={i18n}
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={onSearchInputChange}
|
||||
placeholder={i18n('icu:FunPanelStickers__SearchPlaceholder')}
|
||||
@@ -325,16 +334,16 @@ export function FunPanelStickers({
|
||||
<FunResults aria-busy={false}>
|
||||
<FunResultsHeader>
|
||||
{i18n('icu:FunPanelStickers__SearchResults__EmptyHeading')}{' '}
|
||||
<FunEmoji
|
||||
<FunStaticEmoji
|
||||
size={16}
|
||||
role="presentation"
|
||||
// For presentation only
|
||||
aria-label=""
|
||||
emoji={emojiVariantConstant('\u{1F641}')}
|
||||
/>
|
||||
</FunResultsHeader>
|
||||
</FunResults>
|
||||
)}
|
||||
<FunLightboxProvider containerRef={scrollerRef}>
|
||||
<StickersLightbox i18n={i18n} stickerLookup={stickerLookup} />
|
||||
<FunKeyboard
|
||||
scrollerRef={scrollerRef}
|
||||
keyboard={keyboard}
|
||||
@@ -392,6 +401,7 @@ export function FunPanelStickers({
|
||||
})}
|
||||
</FunGridContainer>
|
||||
</FunKeyboard>
|
||||
</FunLightboxProvider>
|
||||
</FunScroller>
|
||||
</FunPanel>
|
||||
);
|
||||
@@ -467,8 +477,46 @@ const Cell = memo(function Cell(props: {
|
||||
aria-label={sticker.emoji ?? 'Sticker'}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<FunItemSticker src={sticker.url} />
|
||||
<FunSticker role="presentation" src={sticker.url} size={68} />
|
||||
</FunItemButton>
|
||||
</FunGridCell>
|
||||
);
|
||||
});
|
||||
|
||||
function StickersLightbox(props: {
|
||||
i18n: LocalizerType;
|
||||
stickerLookup: StickerLookup;
|
||||
}) {
|
||||
const { i18n } = props;
|
||||
const key = useFunLightboxKey();
|
||||
const sticker = useMemo(() => {
|
||||
if (key == null) {
|
||||
return null;
|
||||
}
|
||||
const [, , ...stickerIdParts] = key.split('-');
|
||||
const stickerId = stickerIdParts.join('-');
|
||||
const found = props.stickerLookup[stickerId];
|
||||
strictAssert(found, `Must have sticker for "${stickerId}"`);
|
||||
return found;
|
||||
}, [props.stickerLookup, key]);
|
||||
if (sticker == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<FunLightboxPortal>
|
||||
<FunLightboxBackdrop>
|
||||
<FunLightboxDialog
|
||||
aria-label={i18n('icu:FunPanelStickers__LightboxDialog__Label')}
|
||||
>
|
||||
<FunSticker
|
||||
role="img"
|
||||
aria-label={sticker.emoji ?? ''}
|
||||
src={sticker.url}
|
||||
size={512}
|
||||
ignoreReducedMotion
|
||||
/>
|
||||
</FunLightboxDialog>
|
||||
</FunLightboxBackdrop>
|
||||
</FunLightboxPortal>
|
||||
);
|
||||
}
|
||||
|
6
ts/components/fun/types.tsx
Normal file
6
ts/components/fun/types.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export type FunImageAriaProps =
|
||||
| Readonly<{ role: 'img'; 'aria-label': string }>
|
||||
| Readonly<{ role: 'presentation'; 'aria-label'?: never }>;
|
@@ -166,8 +166,9 @@ function InstallScreenQrCode(
|
||||
case LoadingState.Loading:
|
||||
contents = <Spinner size="24px" svgSize="small" />;
|
||||
break;
|
||||
case LoadingState.LoadFailed:
|
||||
switch (props.error) {
|
||||
case LoadingState.LoadFailed: {
|
||||
const { error } = props;
|
||||
switch (error) {
|
||||
case InstallScreenQRCodeError.Timeout:
|
||||
contents = (
|
||||
<>
|
||||
@@ -229,9 +230,10 @@ function InstallScreenQrCode(
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(props.error);
|
||||
throw missingCaseError(error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LoadingState.Loaded:
|
||||
contents = <QRCodeImage i18n={i18n} link={props.value} />;
|
||||
break;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user