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",
|
"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"
|
"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": {
|
"icu:chooseDeviceName": {
|
||||||
"messageformat": "Choose this device's name",
|
"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"
|
"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",
|
"messageformat": "No stickers found",
|
||||||
"description": "FunPicker > Stickers Panel > Search Results > Empty State > Heading"
|
"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": {
|
"icu:FunPanelGifs__SearchLabel--Tenor": {
|
||||||
"messageformat": "Search GIFs via Tenor",
|
"messageformat": "Search GIFs via Tenor",
|
||||||
"description": "FunPicker > GIFs Panel > Search Input > Label (Must use brand name 'Tenor')"
|
"description": "FunPicker > GIFs Panel > Search Input > Label (Must use brand name 'Tenor')"
|
||||||
@@ -3235,6 +3243,42 @@
|
|||||||
"messageformat": "No GIFs found",
|
"messageformat": "No GIFs found",
|
||||||
"description": "FunPicker > Gifs Panel > Search Results > Empty State > Heading"
|
"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": {
|
"icu:confirmation-dialog--Cancel": {
|
||||||
"messageformat": "Cancel",
|
"messageformat": "Cancel",
|
||||||
"description": "Appears on the cancel button in confirmation dialogs."
|
"description": "Appears on the cancel button in confirmation dialogs."
|
||||||
@@ -5315,6 +5359,18 @@
|
|||||||
"messageformat": "Add an Emoji, Sticker, or GIF",
|
"messageformat": "Add an Emoji, Sticker, or GIF",
|
||||||
"description": "Composition Area > Fun Button > Accessibility label"
|
"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": {
|
"icu:CompositionInput__editing-message": {
|
||||||
"messageformat": "Edit message",
|
"messageformat": "Edit message",
|
||||||
"description": "Status text displayed above composition input when editing a message"
|
"description": "Status text displayed above composition input when editing a message"
|
||||||
@@ -7833,6 +7889,18 @@
|
|||||||
"messageformat": "Edited",
|
"messageformat": "Edited",
|
||||||
"description": "label for an edited message"
|
"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": {
|
"icu:EditHistoryMessagesModal__title": {
|
||||||
"messageformat": "Edit history",
|
"messageformat": "Edit history",
|
||||||
"description": "Modal title for the edit history messages modal"
|
"description": "Modal title for the edit history messages modal"
|
||||||
|
@@ -590,9 +590,10 @@
|
|||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"!node_modules/underscore/**",
|
"!node_modules/underscore/**",
|
||||||
"!node_modules/emoji-datasource/emoji_pretty.json",
|
"!node_modules/emoji-datasource/emoji_pretty.json",
|
||||||
"!node_modules/emoji-datasource/**/*.png",
|
"!node_modules/emoji-datasource/img/**/*.png",
|
||||||
"!node_modules/emoji-datasource-apple/emoji_pretty.json",
|
"node_modules/emoji-datasource/categories.json",
|
||||||
"!node_modules/emoji-datasource-apple/img/apple/sheets*",
|
"node_modules/emoji-datasource/emoji.json",
|
||||||
|
"!node_modules/emoji-datasource-apple/**",
|
||||||
"!node_modules/spellchecker/vendor/hunspell/**/*",
|
"!node_modules/spellchecker/vendor/hunspell/**/*",
|
||||||
"!node_modules/@formatjs/intl-displaynames/**/*",
|
"!node_modules/@formatjs/intl-displaynames/**/*",
|
||||||
"!node_modules/@formatjs/intl-listformat/**/*",
|
"!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;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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
|
||||||
|
|
||||||
.module-last-seen-indicator {
|
.module-last-seen-indicator {
|
||||||
|
@@ -56,7 +56,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.CallingReactionsToasts__reaction .module-emoji {
|
.CallingReactionsToasts__reaction .FunStaticEmoji {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
// Float the emoji outside of the toast bubble
|
// Float the emoji outside of the toast bubble
|
||||||
inset-inline-start: -60px;
|
inset-inline-start: -60px;
|
||||||
|
@@ -24,12 +24,6 @@
|
|||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
font-style: normal;
|
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;
|
transition: background 200ms variables.$ease-out-expo;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-emoji {
|
.FunStaticEmoji {
|
||||||
transform: scale(
|
transform: scale(
|
||||||
math.div($button-content-size, $emoji-size-from-component)
|
math.div($button-content-size, $emoji-size-from-component)
|
||||||
);
|
);
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
|
|
||||||
&--emoji {
|
&--emoji {
|
||||||
@mixin focus-or-hover-styles {
|
@mixin focus-or-hover-styles {
|
||||||
.module-emoji {
|
.FunStaticEmoji {
|
||||||
transform: scale(
|
transform: scale(
|
||||||
math.div($big-emoji-size, $emoji-size-from-component)
|
math.div($big-emoji-size, $emoji-size-from-component)
|
||||||
)
|
)
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
&--selected {
|
&--selected {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
||||||
.module-emoji {
|
.FunStaticEmoji {
|
||||||
transform: scale(
|
transform: scale(
|
||||||
math.div($big-emoji-size, $emoji-size-from-component)
|
math.div($big-emoji-size, $emoji-size-from-component)
|
||||||
);
|
);
|
||||||
|
@@ -3,15 +3,18 @@
|
|||||||
|
|
||||||
@use './FunConstants.scss';
|
@use './FunConstants.scss';
|
||||||
@use './FunEmoji.scss';
|
@use './FunEmoji.scss';
|
||||||
|
@use './FunGif.scss';
|
||||||
@use './FunGrid.scss';
|
@use './FunGrid.scss';
|
||||||
@use './FunImage.scss';
|
@use './FunImage.scss';
|
||||||
@use './FunItem.scss';
|
@use './FunItem.scss';
|
||||||
|
@use './FunLightbox.scss';
|
||||||
@use './FunPanel.scss';
|
@use './FunPanel.scss';
|
||||||
@use './FunResults.scss';
|
@use './FunResults.scss';
|
||||||
@use './FunPopover.scss';
|
@use './FunPopover.scss';
|
||||||
@use './FunScroller.scss';
|
@use './FunScroller.scss';
|
||||||
@use './FunSearch.scss';
|
@use './FunSearch.scss';
|
||||||
@use './FunSkinTones.scss';
|
@use './FunSkinTones.scss';
|
||||||
|
@use './FunSticker.scss';
|
||||||
@use './FunSubNav.scss';
|
@use './FunSubNav.scss';
|
||||||
@use './FunTabs.scss';
|
@use './FunTabs.scss';
|
||||||
@use './FunWaterfall.scss';
|
@use './FunWaterfall.scss';
|
||||||
|
@@ -1,27 +1,206 @@
|
|||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
.FunEmoji {
|
// There are 62 rows and columns in the generated sprite sheet.
|
||||||
display: inline-block;
|
$emoji-sprite-sheet-grid-item-count: 62;
|
||||||
flex: none;
|
|
||||||
contain: strict;
|
@mixin emoji-sprite($sheet, $margin, $scale) {
|
||||||
vertical-align: top;
|
$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;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
background-image: url('../images/emoji-sheet-32.webp');
|
// Use 32px variant even on smaller sizes to avoid shipping the 16px sheet
|
||||||
background-size: 1054px;
|
@include emoji-sprite($sheet: 32, $margin: 1px, $scale: calc(16 / 32));
|
||||||
background-position-x: calc(var(--fun-emoji-sheet-x) * -17px - 0.5px);
|
|
||||||
background-position-y: calc(var(--fun-emoji-sheet-y) * -17px - 0.5px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background-image: url('../images/emoji-sheet-64.webp');
|
@include emoji-sprite($sheet: 64, $margin: 1px, $scale: calc(32 / 64));
|
||||||
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);
|
.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 {
|
.FunGrid__HeaderText {
|
||||||
padding-block: 4px;
|
padding-block: 4px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@include mixins.font-body-1;
|
@include mixins.font-body-2;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: light-dark(
|
color: light-dark(
|
||||||
variables.$color-black-alpha-50,
|
variables.$color-black-alpha-50,
|
||||||
|
@@ -16,6 +16,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
padding: 2px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid FunConstants.$Fun__BgColor;
|
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-image-size: 20px;
|
||||||
$icon-actual-size: 18px;
|
$icon-actual-size: 18px;
|
||||||
$icon-margin-inline-start: 12px;
|
$icon-margin-inline-start: 12px;
|
||||||
|
$clear-padding-inline: 6px;
|
||||||
|
$clear-button-size: 20px;
|
||||||
|
$clear-icon-size: 16px;
|
||||||
$input-padding-inline: 12px;
|
$input-padding-inline: 12px;
|
||||||
|
|
||||||
.FunSearch__Container {
|
.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;
|
display: flex;
|
||||||
width: 100cqw;
|
|
||||||
height: 100cqh;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@include mixins.font-body-1;
|
width: $clear-button-size;
|
||||||
color: light-dark(variables.$color-gray-90, variables.$color-gray-05);
|
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 {
|
.FunSkinTones__ListBoxItem {
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
// Handled in .FunSkinTones__ListBoxItem
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.FunSkinTones__ListBoxItemButton {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid FunConstants.$Fun__BgColor;
|
|
||||||
|
|
||||||
&:hover,
|
.FunSkinTones__ListBoxItem:hover &,
|
||||||
&:focus {
|
.FunSkinTones__ListBoxItem:focus & {
|
||||||
background: light-dark(variables.$color-gray-02, variables.$color-gray-78);
|
background: light-dark(variables.$color-gray-02, variables.$color-gray-78);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
.FunSkinTones__ListBoxItem:focus & {
|
||||||
outline: none;
|
|
||||||
@include mixins.keyboard-mode {
|
@include mixins.keyboard-mode {
|
||||||
outline: 2px solid variables.$color-ultramarine;
|
outline: 2px solid variables.$color-ultramarine;
|
||||||
outline-offset: -2px;
|
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 {
|
.FunTabs__TabButton {
|
||||||
// Note: This must not have z-index for the animation
|
// Note: This must not have z-index for the animation
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-block: 2px;
|
padding-block: 5px;
|
||||||
padding-inline: 12px;
|
padding-inline: 12px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@@ -8,9 +8,6 @@
|
|||||||
@use 'global';
|
@use 'global';
|
||||||
@use 'titlebar';
|
@use 'titlebar';
|
||||||
|
|
||||||
// Old style: components
|
|
||||||
@use 'emoji';
|
|
||||||
|
|
||||||
// Old style: modules
|
// Old style: modules
|
||||||
@use 'modules';
|
@use 'modules';
|
||||||
|
|
||||||
@@ -97,6 +94,7 @@
|
|||||||
@use 'components/DeleteMessagesModal.scss';
|
@use 'components/DeleteMessagesModal.scss';
|
||||||
@use 'components/DisappearingTimeDialog.scss';
|
@use 'components/DisappearingTimeDialog.scss';
|
||||||
@use 'components/DisappearingTimerSelect.scss';
|
@use 'components/DisappearingTimerSelect.scss';
|
||||||
|
@use 'components/DraftGifMessageSendModal.scss';
|
||||||
@use 'components/EditConversationAttributesModal.scss';
|
@use 'components/EditConversationAttributesModal.scss';
|
||||||
@use 'components/EditHistoryMessagesModal.scss';
|
@use 'components/EditHistoryMessagesModal.scss';
|
||||||
@use 'components/EditNicknameAndNoteModal.scss';
|
@use 'components/EditNicknameAndNoteModal.scss';
|
||||||
|
@@ -4,8 +4,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { animated, to as interpolate, useSprings } from '@react-spring/web';
|
import { animated, to as interpolate, useSprings } from '@react-spring/web';
|
||||||
import { random } from 'lodash';
|
import { random } from 'lodash';
|
||||||
import { Emojify } from './conversation/Emojify';
|
|
||||||
import { useReducedMotion } from '../hooks/useReducedMotion';
|
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 = {
|
export type PropsType = {
|
||||||
emoji: string;
|
emoji: string;
|
||||||
@@ -39,6 +45,10 @@ export function AnimatedEmojiGalore({
|
|||||||
emoji,
|
emoji,
|
||||||
onAnimationEnd,
|
onAnimationEnd,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
|
strictAssert(isEmojiVariantValue(emoji), 'Must be valid english short name');
|
||||||
|
const emojiVariantKey = getEmojiVariantKeyByValue(emoji);
|
||||||
|
const emojiVariant = getEmojiVariantByKey(emojiVariantKey);
|
||||||
|
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
const [springs] = useSprings(NUM_EMOJIS, i => ({
|
const [springs] = useSprings(NUM_EMOJIS, i => ({
|
||||||
...to(i, onAnimationEnd),
|
...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>
|
</animated.div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
@@ -164,7 +164,7 @@ export function AnimatedEmoji({
|
|||||||
y,
|
y,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Emojify sizeClass="medium" text={value} />
|
<Emojify fontSizeOverride={36} text={value} />
|
||||||
</animated.div>
|
</animated.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -75,7 +75,6 @@ import { handleOutsideClick } from '../util/handleOutsideClick';
|
|||||||
import { Spinner } from './Spinner';
|
import { Spinner } from './Spinner';
|
||||||
import type { Props as ReactionPickerProps } from './conversation/ReactionPicker';
|
import type { Props as ReactionPickerProps } from './conversation/ReactionPicker';
|
||||||
import type { SmartReactionPicker } from '../state/smart/ReactionPicker';
|
import type { SmartReactionPicker } from '../state/smart/ReactionPicker';
|
||||||
import { Emoji } from './emoji/Emoji';
|
|
||||||
import {
|
import {
|
||||||
CallingRaisedHandsList,
|
CallingRaisedHandsList,
|
||||||
CallingRaisedHandsListButton,
|
CallingRaisedHandsListButton,
|
||||||
@@ -86,10 +85,18 @@ import {
|
|||||||
useCallReactionBursts,
|
useCallReactionBursts,
|
||||||
} from './CallReactionBurst';
|
} from './CallReactionBurst';
|
||||||
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
|
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
|
||||||
import { assertDev } from '../util/assert';
|
import { assertDev, strictAssert } from '../util/assert';
|
||||||
import { emojiToData } from './emoji/lib';
|
import { emojiToData } from './emoji/lib';
|
||||||
import { CallingPendingParticipants } from './CallingPendingParticipants';
|
import { CallingPendingParticipants } from './CallingPendingParticipants';
|
||||||
import type { CallingImageDataCache } from './CallManager';
|
import type { CallingImageDataCache } from './CallManager';
|
||||||
|
import { FunStaticEmoji } from './fun/FunEmoji';
|
||||||
|
import {
|
||||||
|
getEmojiParentByKey,
|
||||||
|
getEmojiParentKeyByVariantKey,
|
||||||
|
getEmojiVariantByKey,
|
||||||
|
getEmojiVariantKeyByValue,
|
||||||
|
isEmojiVariantValue,
|
||||||
|
} from './fun/data/emojis';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
activeCall: ActiveCallType;
|
activeCall: ActiveCallType;
|
||||||
@@ -1242,13 +1249,25 @@ function useReactionsToast(props: UseReactionsToastType): void {
|
|||||||
reactions.forEach(({ timestamp, demuxId, value }) => {
|
reactions.forEach(({ timestamp, demuxId, value }) => {
|
||||||
const conversation = conversationsByDemuxId.get(demuxId);
|
const conversation = conversationsByDemuxId.get(demuxId);
|
||||||
const key = `reactions-${timestamp}-${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({
|
showToast({
|
||||||
key,
|
key,
|
||||||
onlyShowOnce: true,
|
onlyShowOnce: true,
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
content: (
|
content: (
|
||||||
<span className="CallingReactionsToasts__reaction">
|
<span className="CallingReactionsToasts__reaction">
|
||||||
<Emoji size={28} emoji={value} />
|
<FunStaticEmoji
|
||||||
|
role="img"
|
||||||
|
aria-label={emojiParent.englishShortNameDefault}
|
||||||
|
size={28}
|
||||||
|
emoji={emojiVariant}
|
||||||
|
/>
|
||||||
{demuxId === localDemuxId ||
|
{demuxId === localDemuxId ||
|
||||||
(ourServiceId && conversation?.serviceId === ourServiceId)
|
(ourServiceId && conversation?.serviceId === ourServiceId)
|
||||||
? i18n('icu:CallingReactions--me')
|
? i18n('icu:CallingReactions--me')
|
||||||
|
@@ -15,6 +15,7 @@ import { RecordingState } from '../types/AudioRecorder';
|
|||||||
import { ConversationColors } from '../types/Colors';
|
import { ConversationColors } from '../types/Colors';
|
||||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||||
import { PaymentEventKind } from '../types/Payment';
|
import { PaymentEventKind } from '../types/Payment';
|
||||||
|
import { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
@@ -84,9 +85,9 @@ export default {
|
|||||||
sortedGroupMembers: [],
|
sortedGroupMembers: [],
|
||||||
// EmojiButton
|
// EmojiButton
|
||||||
onPickEmoji: action('onPickEmoji'),
|
onPickEmoji: action('onPickEmoji'),
|
||||||
onSetSkinTone: action('onSetSkinTone'),
|
onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'),
|
||||||
recentEmojis: [],
|
recentEmojis: [],
|
||||||
skinTone: 1,
|
emojiSkinToneDefault: EmojiSkinTone.Type1,
|
||||||
// StickerButton
|
// StickerButton
|
||||||
knownPacks: [],
|
knownPacks: [],
|
||||||
receivedPacks: [],
|
receivedPacks: [],
|
||||||
|
@@ -84,10 +84,9 @@ import * as RemoteConfig from '../RemoteConfig';
|
|||||||
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis';
|
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis';
|
||||||
import type { FunStickerSelection } from './fun/panels/FunPanelStickers';
|
import type { FunStickerSelection } from './fun/panels/FunPanelStickers';
|
||||||
import type { FunGifSelection } from './fun/panels/FunPanelGifs';
|
import type { FunGifSelection } from './fun/panels/FunPanelGifs';
|
||||||
import { tenorDownload } from './fun/data/tenor';
|
import type { SmartDraftGifMessageSendModalProps } from '../state/smart/DraftGifMessageSendModal';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
|
||||||
import { SKIN_TONE_TO_NUMBER } from './fun/data/emojis';
|
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
|
|
||||||
export type OwnProps = Readonly<{
|
export type OwnProps = Readonly<{
|
||||||
acceptedMessageRequest: boolean | null;
|
acceptedMessageRequest: boolean | null;
|
||||||
@@ -205,6 +204,9 @@ export type OwnProps = Readonly<{
|
|||||||
payload: ForwardMessagesPayload,
|
payload: ForwardMessagesPayload,
|
||||||
onForward: () => void
|
onForward: () => void
|
||||||
) => void;
|
) => void;
|
||||||
|
toggleDraftGifMessageSendModal: (
|
||||||
|
props: SmartDraftGifMessageSendModalProps | null
|
||||||
|
) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type Props = Pick<
|
export type Props = Pick<
|
||||||
@@ -221,7 +223,10 @@ export type Props = Pick<
|
|||||||
> &
|
> &
|
||||||
Pick<
|
Pick<
|
||||||
EmojiButtonProps,
|
EmojiButtonProps,
|
||||||
'onPickEmoji' | 'onSetSkinTone' | 'recentEmojis' | 'skinTone'
|
| 'onPickEmoji'
|
||||||
|
| 'onEmojiSkinToneDefaultChange'
|
||||||
|
| 'recentEmojis'
|
||||||
|
| 'emojiSkinToneDefault'
|
||||||
> &
|
> &
|
||||||
Pick<
|
Pick<
|
||||||
StickerButtonProps,
|
StickerButtonProps,
|
||||||
@@ -304,9 +309,9 @@ export const CompositionArea = memo(function CompositionArea({
|
|||||||
sortedGroupMembers,
|
sortedGroupMembers,
|
||||||
// EmojiButton
|
// EmojiButton
|
||||||
onPickEmoji,
|
onPickEmoji,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
// StickerButton
|
// StickerButton
|
||||||
knownPacks,
|
knownPacks,
|
||||||
receivedPacks,
|
receivedPacks,
|
||||||
@@ -358,6 +363,8 @@ export const CompositionArea = memo(function CompositionArea({
|
|||||||
selectedMessageIds,
|
selectedMessageIds,
|
||||||
toggleSelectMode,
|
toggleSelectMode,
|
||||||
toggleForwardMessagesModal,
|
toggleForwardMessagesModal,
|
||||||
|
// DraftGifMessageSendModal
|
||||||
|
toggleDraftGifMessageSendModal,
|
||||||
}: Props): JSX.Element | null {
|
}: Props): JSX.Element | null {
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [large, setLarge] = useState(false);
|
const [large, setLarge] = useState(false);
|
||||||
@@ -603,14 +610,9 @@ export const CompositionArea = memo(function CompositionArea({
|
|||||||
|
|
||||||
const handleFunPickerSelectEmoji = useCallback(
|
const handleFunPickerSelectEmoji = useCallback(
|
||||||
(emojiSelection: FunEmojiSelection) => {
|
(emojiSelection: FunEmojiSelection) => {
|
||||||
const skinToneNumber = SKIN_TONE_TO_NUMBER.get(emojiSelection.skinTone);
|
|
||||||
strictAssert(
|
|
||||||
skinToneNumber,
|
|
||||||
`Unexpected skin tone: ${emojiSelection.skinTone}`
|
|
||||||
);
|
|
||||||
insertEmoji({
|
insertEmoji({
|
||||||
shortName: emojiSelection.englishShortName,
|
shortName: emojiSelection.englishShortName,
|
||||||
skinTone: skinToneNumber,
|
skinTone: emojiSelection.skinTone,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[insertEmoji]
|
[insertEmoji]
|
||||||
@@ -625,24 +627,53 @@ export const CompositionArea = memo(function CompositionArea({
|
|||||||
[sendStickerMessage, conversationId]
|
[sendStickerMessage, conversationId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [confirmGifSelection, setConfirmGifSelection] =
|
||||||
|
useState<FunGifSelection | null>(null);
|
||||||
|
|
||||||
const handleFunPickerSelectGif = useCallback(
|
const handleFunPickerSelectGif = useCallback(
|
||||||
async (gifSelection: FunGifSelection) => {
|
async (gifSelection: FunGifSelection) => {
|
||||||
const { url } = gifSelection.attachmentMedia;
|
if (draftAttachments.length > 0) {
|
||||||
|
setConfirmGifSelection(gifSelection);
|
||||||
const bytes = await tenorDownload(url);
|
} else {
|
||||||
const file = new File([bytes], 'gif.mp4', {
|
toggleDraftGifMessageSendModal({
|
||||||
type: 'video/mp4',
|
conversationId,
|
||||||
});
|
previousComposerDraftText: draftText ?? '',
|
||||||
|
previousComposerDraftBodyRanges: draftBodyRanges ?? [],
|
||||||
processAttachments({
|
gifSelection,
|
||||||
conversationId,
|
});
|
||||||
files: [file],
|
}
|
||||||
flags: Proto.AttachmentPointer.Flags.GIF,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[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(() => {
|
const handleFunPickerAddStickerPack = useCallback(() => {
|
||||||
pushPanelForConversation({
|
pushPanelForConversation({
|
||||||
type: PanelType.StickerManager,
|
type: PanelType.StickerManager,
|
||||||
@@ -651,6 +682,27 @@ export const CompositionArea = memo(function CompositionArea({
|
|||||||
|
|
||||||
const leftHandSideButtonsFragment = (
|
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 && (
|
{isFunPickerEnabled && (
|
||||||
<div className="CompositionArea__button-cell">
|
<div className="CompositionArea__button-cell">
|
||||||
<FunPicker
|
<FunPicker
|
||||||
@@ -677,8 +729,8 @@ export const CompositionArea = memo(function CompositionArea({
|
|||||||
onPickEmoji={insertEmoji}
|
onPickEmoji={insertEmoji}
|
||||||
onClose={() => setComposerFocus(conversationId)}
|
onClose={() => setComposerFocus(conversationId)}
|
||||||
recentEmojis={recentEmojis}
|
recentEmojis={recentEmojis}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1057,7 +1109,7 @@ export const CompositionArea = memo(function CompositionArea({
|
|||||||
ourConversationId={ourConversationId}
|
ourConversationId={ourConversationId}
|
||||||
platform={platform}
|
platform={platform}
|
||||||
recentStickers={recentStickers}
|
recentStickers={recentStickers}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
sortedGroupMembers={sortedGroupMembers}
|
sortedGroupMembers={sortedGroupMembers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1155,7 +1207,7 @@ export const CompositionArea = memo(function CompositionArea({
|
|||||||
quotedMessageId={quotedMessageId}
|
quotedMessageId={quotedMessageId}
|
||||||
sendCounter={sendCounter}
|
sendCounter={sendCounter}
|
||||||
shouldHidePopovers={shouldHidePopovers}
|
shouldHidePopovers={shouldHidePopovers}
|
||||||
skinTone={skinTone ?? null}
|
emojiSkinToneDefault={emojiSkinToneDefault ?? null}
|
||||||
sortedGroupMembers={sortedGroupMembers}
|
sortedGroupMembers={sortedGroupMembers}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
|
@@ -11,6 +11,7 @@ import type { Props } from './CompositionInput';
|
|||||||
import { CompositionInput } from './CompositionInput';
|
import { CompositionInput } from './CompositionInput';
|
||||||
import { generateAci } from '../types/ServiceId';
|
import { generateAci } from '../types/ServiceId';
|
||||||
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
|
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
|
||||||
|
import { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => {
|
|||||||
quotedMessageId: null,
|
quotedMessageId: null,
|
||||||
sendCounter: 0,
|
sendCounter: 0,
|
||||||
sortedGroupMembers: overrideProps.sortedGroupMembers ?? [],
|
sortedGroupMembers: overrideProps.sortedGroupMembers ?? [],
|
||||||
skinTone: overrideProps.skinTone ?? null,
|
emojiSkinToneDefault:
|
||||||
|
overrideProps.emojiSkinToneDefault ?? EmojiSkinTone.None,
|
||||||
theme: React.useContext(StorybookThemeContext),
|
theme: React.useContext(StorybookThemeContext),
|
||||||
inputApi: null,
|
inputApi: null,
|
||||||
shouldHidePopovers: null,
|
shouldHidePopovers: null,
|
||||||
|
@@ -73,9 +73,12 @@ import {
|
|||||||
matchStrikethrough,
|
matchStrikethrough,
|
||||||
} from '../quill/formatting/matchers';
|
} from '../quill/formatting/matchers';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
import type { AutoSubstituteAsciiEmojisOptions } from '../quill/auto-substitute-ascii-emojis';
|
||||||
import { AutoSubstituteAsciiEmojis } from '../quill/auto-substitute-ascii-emojis';
|
import { AutoSubstituteAsciiEmojis } from '../quill/auto-substitute-ascii-emojis';
|
||||||
import { dropNull } from '../util/dropNull';
|
import { dropNull } from '../util/dropNull';
|
||||||
import { SimpleQuillWrapper } from './SimpleQuillWrapper';
|
import { SimpleQuillWrapper } from './SimpleQuillWrapper';
|
||||||
|
import type { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
import { FUN_STATIC_EMOJI_CLASS } from './fun/FunEmoji';
|
||||||
|
|
||||||
Quill.register(
|
Quill.register(
|
||||||
{
|
{
|
||||||
@@ -117,7 +120,7 @@ export type Props = Readonly<{
|
|||||||
isFormattingEnabled: boolean;
|
isFormattingEnabled: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
sendCounter: number;
|
sendCounter: number;
|
||||||
skinTone: NonNullable<EmojiPickDataType['skinTone']> | null;
|
emojiSkinToneDefault: EmojiSkinTone;
|
||||||
draftText: string | null;
|
draftText: string | null;
|
||||||
draftBodyRanges: HydratedBodyRangesType | null;
|
draftBodyRanges: HydratedBodyRangesType | null;
|
||||||
moduleClassName?: string;
|
moduleClassName?: string;
|
||||||
@@ -182,7 +185,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||||||
platform,
|
platform,
|
||||||
quotedMessageId,
|
quotedMessageId,
|
||||||
shouldHidePopovers,
|
shouldHidePopovers,
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
sendCounter,
|
sendCounter,
|
||||||
sortedGroupMembers,
|
sortedGroupMembers,
|
||||||
theme,
|
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
|
// 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
|
// quill doesn't know about. It can result formatting on the resultant message that
|
||||||
// doesn't match the composer.
|
// 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) {
|
for (const node of withStyles) {
|
||||||
node.attributes.removeNamedItem('style');
|
node.attributes.removeNamedItem('style');
|
||||||
}
|
}
|
||||||
@@ -689,12 +694,12 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const emojiCompletion = emojiCompletionRef.current;
|
const emojiCompletion = emojiCompletionRef.current;
|
||||||
|
|
||||||
if (emojiCompletion == null || skinTone == null) {
|
if (emojiCompletion == null || emojiSkinToneDefault == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emojiCompletion.options.skinTone = skinTone;
|
emojiCompletion.options.emojiSkinToneDefault = emojiSkinToneDefault;
|
||||||
}, [skinTone]);
|
}, [emojiSkinToneDefault]);
|
||||||
|
|
||||||
React.useEffect(
|
React.useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -790,7 +795,9 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||||||
['br', matchBreak],
|
['br', matchBreak],
|
||||||
[Node.ELEMENT_NODE, matchNewline],
|
[Node.ELEMENT_NODE, matchNewline],
|
||||||
['IMG', matchEmojiImage],
|
['IMG', matchEmojiImage],
|
||||||
|
['SPAN', matchEmojiImage],
|
||||||
['IMG', matchEmojiBlot],
|
['IMG', matchEmojiBlot],
|
||||||
|
['SPAN', matchEmojiBlot],
|
||||||
['STRONG', matchBold],
|
['STRONG', matchBold],
|
||||||
['EM', matchItalic],
|
['EM', matchItalic],
|
||||||
['SPAN', matchMonospace],
|
['SPAN', matchMonospace],
|
||||||
@@ -831,12 +838,12 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||||||
setEmojiPickerElement: setEmojiCompletionElement,
|
setEmojiPickerElement: setEmojiCompletionElement,
|
||||||
onPickEmoji: (emoji: EmojiPickDataType) =>
|
onPickEmoji: (emoji: EmojiPickDataType) =>
|
||||||
callbacksRef.current.onPickEmoji(emoji),
|
callbacksRef.current.onPickEmoji(emoji),
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
search,
|
search,
|
||||||
},
|
},
|
||||||
autoSubstituteAsciiEmojis: {
|
autoSubstituteAsciiEmojis: {
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
},
|
} satisfies AutoSubstituteAsciiEmojisOptions,
|
||||||
formattingMenu: {
|
formattingMenu: {
|
||||||
i18n,
|
i18n,
|
||||||
isMenuEnabled: isFormattingEnabled,
|
isMenuEnabled: isFormattingEnabled,
|
||||||
|
@@ -15,6 +15,7 @@ import type { ThemeType } from '../types/Util';
|
|||||||
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
|
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
|
||||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
import * as grapheme from '../util/grapheme';
|
import * as grapheme from '../util/grapheme';
|
||||||
|
import type { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
export type CompositionTextAreaProps = {
|
export type CompositionTextAreaProps = {
|
||||||
bodyRanges: HydratedBodyRangesType | null;
|
bodyRanges: HydratedBodyRangesType | null;
|
||||||
@@ -31,7 +32,7 @@ export type CompositionTextAreaProps = {
|
|||||||
draftBodyRanges: HydratedBodyRangesType,
|
draftBodyRanges: HydratedBodyRangesType,
|
||||||
caretLocation?: number | undefined
|
caretLocation?: number | undefined
|
||||||
) => void;
|
) => void;
|
||||||
onSetSkinTone: (tone: number) => void;
|
onEmojiSkinToneDefaultChange: (emojiSkinToneDefault: EmojiSkinTone) => void;
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
message: string,
|
message: string,
|
||||||
draftBodyRanges: DraftBodyRanges,
|
draftBodyRanges: DraftBodyRanges,
|
||||||
@@ -43,7 +44,7 @@ export type CompositionTextAreaProps = {
|
|||||||
getPreferredBadge: PreferredBadgeSelectorType;
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
draftText: string;
|
draftText: string;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
} & Pick<EmojiButtonProps, 'recentEmojis' | 'emojiSkinToneDefault'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Essentially an HTML textarea but with support for emoji picker and
|
* Essentially an HTML textarea but with support for emoji picker and
|
||||||
@@ -63,14 +64,14 @@ export function CompositionTextArea({
|
|||||||
onChange,
|
onChange,
|
||||||
onPickEmoji,
|
onPickEmoji,
|
||||||
onScroll,
|
onScroll,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onTextTooLong,
|
onTextTooLong,
|
||||||
ourConversationId,
|
ourConversationId,
|
||||||
placeholder,
|
placeholder,
|
||||||
platform,
|
platform,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
theme,
|
theme,
|
||||||
whenToShowRemainingCount = Infinity,
|
whenToShowRemainingCount = Infinity,
|
||||||
}: CompositionTextAreaProps): JSX.Element {
|
}: CompositionTextAreaProps): JSX.Element {
|
||||||
@@ -153,7 +154,7 @@ export function CompositionTextArea({
|
|||||||
quotedMessageId={null}
|
quotedMessageId={null}
|
||||||
sendCounter={0}
|
sendCounter={0}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
skinTone={skinTone ?? null}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
// These do not apply in the forward modal because there isn't
|
// These do not apply in the forward modal because there isn't
|
||||||
// strictly one conversation
|
// strictly one conversation
|
||||||
conversationId={null}
|
conversationId={null}
|
||||||
@@ -170,9 +171,9 @@ export function CompositionTextArea({
|
|||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onClose={focusTextEditInput}
|
onClose={focusTextEditInput}
|
||||||
onPickEmoji={insertEmoji}
|
onPickEmoji={insertEmoji}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
recentEmojis={recentEmojis}
|
recentEmojis={recentEmojis}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{maxLength !== undefined &&
|
{maxLength !== undefined &&
|
||||||
|
@@ -9,6 +9,7 @@ import type { Meta } from '@storybook/react';
|
|||||||
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants';
|
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants';
|
||||||
import type { PropsType } from './CustomizingPreferredReactionsModal';
|
import type { PropsType } from './CustomizingPreferredReactionsModal';
|
||||||
import { CustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal';
|
import { CustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal';
|
||||||
|
import { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ const defaultProps: ComponentProps<typeof CustomizingPreferredReactionsModal> =
|
|||||||
hadSaveError: false,
|
hadSaveError: false,
|
||||||
i18n,
|
i18n,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
onSetSkinTone: action('onSetSkinTone'),
|
onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'),
|
||||||
originalPreferredReactions: DEFAULT_PREFERRED_REACTION_EMOJI,
|
originalPreferredReactions: DEFAULT_PREFERRED_REACTION_EMOJI,
|
||||||
recentEmojis: ['cake'],
|
recentEmojis: ['cake'],
|
||||||
replaceSelectedDraftEmoji: action('replaceSelectedDraftEmoji'),
|
replaceSelectedDraftEmoji: action('replaceSelectedDraftEmoji'),
|
||||||
@@ -34,7 +35,7 @@ const defaultProps: ComponentProps<typeof CustomizingPreferredReactionsModal> =
|
|||||||
savePreferredReactions: action('savePreferredReactions'),
|
savePreferredReactions: action('savePreferredReactions'),
|
||||||
selectDraftEmojiToBeReplaced: action('selectDraftEmojiToBeReplaced'),
|
selectDraftEmojiToBeReplaced: action('selectDraftEmojiToBeReplaced'),
|
||||||
selectedDraftEmojiIndex: undefined,
|
selectedDraftEmojiIndex: undefined,
|
||||||
skinTone: 4,
|
emojiSkinToneDefault: EmojiSkinTone.Type4,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Default(): JSX.Element {
|
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 { convertShortName } from './emoji/lib';
|
||||||
import { offsetDistanceModifier } from '../util/popperUtil';
|
import { offsetDistanceModifier } from '../util/popperUtil';
|
||||||
import { handleOutsideClick } from '../util/handleOutsideClick';
|
import { handleOutsideClick } from '../util/handleOutsideClick';
|
||||||
|
import type { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
draftPreferredReactions: ReadonlyArray<string>;
|
draftPreferredReactions: ReadonlyArray<string>;
|
||||||
@@ -27,11 +28,11 @@ export type PropsType = {
|
|||||||
originalPreferredReactions: ReadonlyArray<string>;
|
originalPreferredReactions: ReadonlyArray<string>;
|
||||||
recentEmojis: ReadonlyArray<string>;
|
recentEmojis: ReadonlyArray<string>;
|
||||||
selectedDraftEmojiIndex: undefined | number;
|
selectedDraftEmojiIndex: undefined | number;
|
||||||
skinTone: number;
|
emojiSkinToneDefault: EmojiSkinTone;
|
||||||
|
|
||||||
cancelCustomizePreferredReactionsModal(): unknown;
|
cancelCustomizePreferredReactionsModal(): unknown;
|
||||||
deselectDraftEmoji(): unknown;
|
deselectDraftEmoji(): unknown;
|
||||||
onSetSkinTone(tone: number): unknown;
|
onEmojiSkinToneDefaultChange: (emojiSkinToneDefault: EmojiSkinTone) => void;
|
||||||
replaceSelectedDraftEmoji(newEmoji: string): unknown;
|
replaceSelectedDraftEmoji(newEmoji: string): unknown;
|
||||||
resetDraftEmoji(): unknown;
|
resetDraftEmoji(): unknown;
|
||||||
savePreferredReactions(): unknown;
|
savePreferredReactions(): unknown;
|
||||||
@@ -42,10 +43,11 @@ export function CustomizingPreferredReactionsModal({
|
|||||||
cancelCustomizePreferredReactionsModal,
|
cancelCustomizePreferredReactionsModal,
|
||||||
deselectDraftEmoji,
|
deselectDraftEmoji,
|
||||||
draftPreferredReactions,
|
draftPreferredReactions,
|
||||||
|
emojiSkinToneDefault,
|
||||||
hadSaveError,
|
hadSaveError,
|
||||||
i18n,
|
i18n,
|
||||||
isSaving,
|
isSaving,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
originalPreferredReactions,
|
originalPreferredReactions,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
replaceSelectedDraftEmoji,
|
replaceSelectedDraftEmoji,
|
||||||
@@ -53,7 +55,6 @@ export function CustomizingPreferredReactionsModal({
|
|||||||
savePreferredReactions,
|
savePreferredReactions,
|
||||||
selectDraftEmojiToBeReplaced,
|
selectDraftEmojiToBeReplaced,
|
||||||
selectedDraftEmojiIndex,
|
selectedDraftEmojiIndex,
|
||||||
skinTone,
|
|
||||||
}: Readonly<PropsType>): JSX.Element {
|
}: Readonly<PropsType>): JSX.Element {
|
||||||
const [referenceElement, setReferenceElement] =
|
const [referenceElement, setReferenceElement] =
|
||||||
useState<null | HTMLDivElement>(null);
|
useState<null | HTMLDivElement>(null);
|
||||||
@@ -98,7 +99,7 @@ export function CustomizingPreferredReactionsModal({
|
|||||||
!isSaving &&
|
!isSaving &&
|
||||||
!isEqual(
|
!isEqual(
|
||||||
DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES.map(shortName =>
|
DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES.map(shortName =>
|
||||||
convertShortName(shortName, skinTone)
|
convertShortName(shortName, emojiSkinToneDefault)
|
||||||
),
|
),
|
||||||
draftPreferredReactions
|
draftPreferredReactions
|
||||||
);
|
);
|
||||||
@@ -188,8 +189,8 @@ export function CustomizingPreferredReactionsModal({
|
|||||||
replaceSelectedDraftEmoji(emoji);
|
replaceSelectedDraftEmoji(emoji);
|
||||||
}}
|
}}
|
||||||
recentEmojis={recentEmojis}
|
recentEmojis={recentEmojis}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
deselectDraftEmoji();
|
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 { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
|
||||||
import { CompositionTextArea } from './CompositionTextArea';
|
import { CompositionTextArea } from './CompositionTextArea';
|
||||||
import type { MessageForwardDraft } from '../types/ForwardDraft';
|
import type { MessageForwardDraft } from '../types/ForwardDraft';
|
||||||
|
import { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
const createAttachment = (
|
const createAttachment = (
|
||||||
props: Partial<AttachmentForUIType> = {}
|
props: Partial<AttachmentForUIType> = {}
|
||||||
@@ -64,11 +65,11 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||||||
isActive
|
isActive
|
||||||
isFormattingEnabled
|
isFormattingEnabled
|
||||||
onPickEmoji={action('onPickEmoji')}
|
onPickEmoji={action('onPickEmoji')}
|
||||||
onSetSkinTone={action('onSetSkinTone')}
|
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
|
||||||
onTextTooLong={action('onTextTooLong')}
|
onTextTooLong={action('onTextTooLong')}
|
||||||
ourConversationId="me"
|
ourConversationId="me"
|
||||||
platform="darwin"
|
platform="darwin"
|
||||||
skinTone={0}
|
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
showToast: action('showToast'),
|
showToast: action('showToast'),
|
||||||
|
@@ -44,6 +44,7 @@ import {
|
|||||||
} from '../types/ForwardDraft';
|
} from '../types/ForwardDraft';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import { Theme } from '../util/theme';
|
import { Theme } from '../util/theme';
|
||||||
|
import { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
export enum ForwardMessagesModalType {
|
export enum ForwardMessagesModalType {
|
||||||
Forward,
|
Forward,
|
||||||
@@ -500,6 +501,7 @@ function ForwardMessageEditor({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -31,6 +31,7 @@ import {
|
|||||||
BackfillFailureModal,
|
BackfillFailureModal,
|
||||||
type DataPropsType as BackfillFailureModalPropsType,
|
type DataPropsType as BackfillFailureModalPropsType,
|
||||||
} from './BackfillFailureModal';
|
} from './BackfillFailureModal';
|
||||||
|
import type { SmartDraftGifMessageSendModalProps } from '../state/smart/DraftGifMessageSendModal';
|
||||||
|
|
||||||
// NOTE: All types should be required for this component so that the smart
|
// NOTE: All types should be required for this component so that the smart
|
||||||
// component gives you type errors when adding/removing props.
|
// component gives you type errors when adding/removing props.
|
||||||
@@ -80,6 +81,9 @@ export type PropsType = {
|
|||||||
// DeleteMessageModal
|
// DeleteMessageModal
|
||||||
deleteMessagesProps: DeleteMessagesPropsType | undefined;
|
deleteMessagesProps: DeleteMessagesPropsType | undefined;
|
||||||
renderDeleteMessagesModal: () => JSX.Element;
|
renderDeleteMessagesModal: () => JSX.Element;
|
||||||
|
// DraftGifMessageSendModal
|
||||||
|
draftGifMessageSendModalProps: SmartDraftGifMessageSendModalProps | null;
|
||||||
|
renderDraftGifMessageSendModal: () => JSX.Element;
|
||||||
// ForwardMessageModal
|
// ForwardMessageModal
|
||||||
forwardMessagesProps: ForwardMessagesPropsType | undefined;
|
forwardMessagesProps: ForwardMessagesPropsType | undefined;
|
||||||
renderForwardMessagesModal: () => JSX.Element;
|
renderForwardMessagesModal: () => JSX.Element;
|
||||||
@@ -180,6 +184,9 @@ export function GlobalModalContainer({
|
|||||||
// DeleteMessageModal
|
// DeleteMessageModal
|
||||||
deleteMessagesProps,
|
deleteMessagesProps,
|
||||||
renderDeleteMessagesModal,
|
renderDeleteMessagesModal,
|
||||||
|
// DraftGifMessageSendModal
|
||||||
|
draftGifMessageSendModalProps,
|
||||||
|
renderDraftGifMessageSendModal,
|
||||||
// ForwardMessageModal
|
// ForwardMessageModal
|
||||||
forwardMessagesProps,
|
forwardMessagesProps,
|
||||||
renderForwardMessagesModal,
|
renderForwardMessagesModal,
|
||||||
@@ -300,6 +307,10 @@ export function GlobalModalContainer({
|
|||||||
return renderDeleteMessagesModal();
|
return renderDeleteMessagesModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (draftGifMessageSendModalProps) {
|
||||||
|
return renderDraftGifMessageSendModal();
|
||||||
|
}
|
||||||
|
|
||||||
if (messageRequestActionsConfirmationProps) {
|
if (messageRequestActionsConfirmationProps) {
|
||||||
return renderMessageRequestActionsConfirmation();
|
return renderMessageRequestActionsConfirmation();
|
||||||
}
|
}
|
||||||
|
@@ -8,6 +8,7 @@ import { action } from '@storybook/addon-actions';
|
|||||||
import type { PropsType } from './MediaEditor';
|
import type { PropsType } from './MediaEditor';
|
||||||
import { MediaEditor } from './MediaEditor';
|
import { MediaEditor } from './MediaEditor';
|
||||||
import { Stickers, installedPacks } from '../test-both/helpers/getStickerPacks';
|
import { Stickers, installedPacks } from '../test-both/helpers/getStickerPacks';
|
||||||
|
import { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
const IMAGE_1 = '/fixtures/nathan-anderson-316188-unsplash.jpg';
|
const IMAGE_1 = '/fixtures/nathan-anderson-316188-unsplash.jpg';
|
||||||
@@ -32,7 +33,7 @@ export default {
|
|||||||
onTextTooLong: action('onTextTooLong'),
|
onTextTooLong: action('onTextTooLong'),
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
recentStickers: [Stickers.wide, Stickers.tall, Stickers.abe],
|
recentStickers: [Stickers.wide, Stickers.tall, Stickers.abe],
|
||||||
skinTone: 0,
|
emojiSkinToneDefault: EmojiSkinTone.None,
|
||||||
},
|
},
|
||||||
} satisfies Meta<PropsType>;
|
} satisfies Meta<PropsType>;
|
||||||
|
|
||||||
|
@@ -163,9 +163,9 @@ export function MediaEditor({
|
|||||||
sortedGroupMembers,
|
sortedGroupMembers,
|
||||||
|
|
||||||
// EmojiPickerProps
|
// EmojiPickerProps
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
|
|
||||||
// StickerButtonProps
|
// StickerButtonProps
|
||||||
installedPacks,
|
installedPacks,
|
||||||
@@ -1313,7 +1313,7 @@ export function MediaEditor({
|
|||||||
setCaptionBodyRanges(bodyRanges);
|
setCaptionBodyRanges(bodyRanges);
|
||||||
setCaption(messageText);
|
setCaption(messageText);
|
||||||
}}
|
}}
|
||||||
skinTone={skinTone ?? null}
|
emojiSkinToneDefault={emojiSkinToneDefault ?? null}
|
||||||
onPickEmoji={onPickEmoji}
|
onPickEmoji={onPickEmoji}
|
||||||
onSubmit={noop}
|
onSubmit={noop}
|
||||||
onTextTooLong={onTextTooLong}
|
onTextTooLong={onTextTooLong}
|
||||||
@@ -1342,8 +1342,8 @@ export function MediaEditor({
|
|||||||
onOpen={() => setEmojiPopperOpen(true)}
|
onOpen={() => setEmojiPopperOpen(true)}
|
||||||
onClose={closeEmojiPickerAndFocusComposer}
|
onClose={closeEmojiPickerAndFocusComposer}
|
||||||
recentEmojis={recentEmojis}
|
recentEmojis={recentEmojis}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
/>
|
/>
|
||||||
</CompositionInput>
|
</CompositionInput>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -11,6 +11,7 @@ import { DEFAULT_CONVERSATION_COLOR } from '../types/Colors';
|
|||||||
import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode';
|
import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode';
|
||||||
import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability';
|
import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability';
|
||||||
import { DurationInSeconds } from '../util/durations';
|
import { DurationInSeconds } from '../util/durations';
|
||||||
|
import { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ export default {
|
|||||||
customColors: {},
|
customColors: {},
|
||||||
defaultConversationColor: DEFAULT_CONVERSATION_COLOR,
|
defaultConversationColor: DEFAULT_CONVERSATION_COLOR,
|
||||||
deviceName: 'Work Windows ME',
|
deviceName: 'Work Windows ME',
|
||||||
|
emojiSkinToneDefault: EmojiSkinTone.None,
|
||||||
phoneNumber: '+1 555 123-4567',
|
phoneNumber: '+1 555 123-4567',
|
||||||
hasAudioNotifications: true,
|
hasAudioNotifications: true,
|
||||||
hasAutoConvertEmoji: true,
|
hasAutoConvertEmoji: true,
|
||||||
@@ -145,6 +147,7 @@ export default {
|
|||||||
'onCallRingtoneNotificationChange'
|
'onCallRingtoneNotificationChange'
|
||||||
),
|
),
|
||||||
onCountMutedConversationsChange: action('onCountMutedConversationsChange'),
|
onCountMutedConversationsChange: action('onCountMutedConversationsChange'),
|
||||||
|
onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'),
|
||||||
onHasStoriesDisabledChanged: action('onHasStoriesDisabledChanged'),
|
onHasStoriesDisabledChanged: action('onHasStoriesDisabledChanged'),
|
||||||
onHideMenuBarChange: action('onHideMenuBarChange'),
|
onHideMenuBarChange: action('onHideMenuBarChange'),
|
||||||
onIncomingCallNotificationsChange: action(
|
onIncomingCallNotificationsChange: action(
|
||||||
|
@@ -68,6 +68,8 @@ import { SearchInput } from './SearchInput';
|
|||||||
import { removeDiacritics } from '../util/removeDiacritics';
|
import { removeDiacritics } from '../util/removeDiacritics';
|
||||||
import { assertDev } from '../util/assert';
|
import { assertDev } from '../util/assert';
|
||||||
import { I18n } from './I18n';
|
import { I18n } from './I18n';
|
||||||
|
import { FunSkinTonesList } from './fun/FunSkinTones';
|
||||||
|
import { emojiParentKeyConstant, type EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
type CheckboxChangeHandlerType = (value: boolean) => unknown;
|
type CheckboxChangeHandlerType = (value: boolean) => unknown;
|
||||||
type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
|
type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
|
||||||
@@ -79,6 +81,7 @@ export type PropsDataType = {
|
|||||||
customColors: Record<string, CustomColorType>;
|
customColors: Record<string, CustomColorType>;
|
||||||
defaultConversationColor: DefaultConversationColorType;
|
defaultConversationColor: DefaultConversationColorType;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
|
emojiSkinToneDefault: EmojiSkinTone;
|
||||||
hasAudioNotifications?: boolean;
|
hasAudioNotifications?: boolean;
|
||||||
hasAutoConvertEmoji: boolean;
|
hasAutoConvertEmoji: boolean;
|
||||||
hasAutoDownloadUpdate: boolean;
|
hasAutoDownloadUpdate: boolean;
|
||||||
@@ -172,6 +175,7 @@ type PropsFunctionType = {
|
|||||||
onCallNotificationsChange: CheckboxChangeHandlerType;
|
onCallNotificationsChange: CheckboxChangeHandlerType;
|
||||||
onCallRingtoneNotificationChange: CheckboxChangeHandlerType;
|
onCallRingtoneNotificationChange: CheckboxChangeHandlerType;
|
||||||
onCountMutedConversationsChange: CheckboxChangeHandlerType;
|
onCountMutedConversationsChange: CheckboxChangeHandlerType;
|
||||||
|
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
|
||||||
onHasStoriesDisabledChanged: SelectChangeHandlerType<boolean>;
|
onHasStoriesDisabledChanged: SelectChangeHandlerType<boolean>;
|
||||||
onHideMenuBarChange: CheckboxChangeHandlerType;
|
onHideMenuBarChange: CheckboxChangeHandlerType;
|
||||||
onIncomingCallNotificationsChange: CheckboxChangeHandlerType;
|
onIncomingCallNotificationsChange: CheckboxChangeHandlerType;
|
||||||
@@ -264,6 +268,7 @@ export function Preferences({
|
|||||||
doDeleteAllData,
|
doDeleteAllData,
|
||||||
doneRendering,
|
doneRendering,
|
||||||
editCustomColor,
|
editCustomColor,
|
||||||
|
emojiSkinToneDefault,
|
||||||
getConversationsWithCustomColor,
|
getConversationsWithCustomColor,
|
||||||
hasAudioNotifications,
|
hasAudioNotifications,
|
||||||
hasAutoConvertEmoji,
|
hasAutoConvertEmoji,
|
||||||
@@ -308,6 +313,7 @@ export function Preferences({
|
|||||||
onCallNotificationsChange,
|
onCallNotificationsChange,
|
||||||
onCallRingtoneNotificationChange,
|
onCallRingtoneNotificationChange,
|
||||||
onCountMutedConversationsChange,
|
onCountMutedConversationsChange,
|
||||||
|
onEmojiSkinToneDefaultChange,
|
||||||
onHasStoriesDisabledChanged,
|
onHasStoriesDisabledChanged,
|
||||||
onHideMenuBarChange,
|
onHideMenuBarChange,
|
||||||
onIncomingCallNotificationsChange,
|
onIncomingCallNotificationsChange,
|
||||||
@@ -892,6 +898,20 @@ export function Preferences({
|
|||||||
name="autoConvertEmoji"
|
name="autoConvertEmoji"
|
||||||
onChange={onAutoConvertEmojiChange}
|
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>
|
</SettingsRow>
|
||||||
{isSyncSupported && (
|
{isSyncSupported && (
|
||||||
<SettingsRow>
|
<SettingsRow>
|
||||||
|
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '../state/ducks/usernameEnums';
|
} from '../state/ducks/usernameEnums';
|
||||||
import { getRandomColor } from '../test-both/helpers/getRandomColor';
|
import { getRandomColor } from '../test-both/helpers/getRandomColor';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
|
import { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
@@ -60,13 +61,13 @@ export default {
|
|||||||
usernameLinkState: UsernameLinkState.Ready,
|
usernameLinkState: UsernameLinkState.Ready,
|
||||||
|
|
||||||
recentEmojis: [],
|
recentEmojis: [],
|
||||||
skinTone: 0,
|
emojiSkinToneDefault: EmojiSkinTone.None,
|
||||||
userAvatarData: [],
|
userAvatarData: [],
|
||||||
username: undefined,
|
username: undefined,
|
||||||
|
|
||||||
onEditStateChanged: action('onEditStateChanged'),
|
onEditStateChanged: action('onEditStateChanged'),
|
||||||
onProfileChanged: action('onProfileChanged'),
|
onProfileChanged: action('onProfileChanged'),
|
||||||
onSetSkinTone: action('onSetSkinTone'),
|
onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'),
|
||||||
saveAttachment: action('saveAttachment'),
|
saveAttachment: action('saveAttachment'),
|
||||||
setUsernameLinkColor: action('setUsernameLinkColor'),
|
setUsernameLinkColor: action('setUsernameLinkColor'),
|
||||||
showToast: action('showToast'),
|
showToast: action('showToast'),
|
||||||
@@ -107,13 +108,15 @@ function renderEditUsernameModalBody(props: {
|
|||||||
|
|
||||||
// eslint-disable-next-line react/function-component-definition
|
// eslint-disable-next-line react/function-component-definition
|
||||||
const Template: StoryFn<PropsType> = args => {
|
const Template: StoryFn<PropsType> = args => {
|
||||||
const [skinTone, setSkinTone] = useState(0);
|
const [emojiSkinToneDefault, setEmojiSkinToneDefault] = useState(
|
||||||
|
EmojiSkinTone.None
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProfileEditor
|
<ProfileEditor
|
||||||
{...args}
|
{...args}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
onSetSkinTone={setSkinTone}
|
onEmojiSkinToneDefaultChange={setEmojiSkinToneDefault}
|
||||||
renderEditUsernameModalBody={renderEditUsernameModalBody}
|
renderEditUsernameModalBody={renderEditUsernameModalBody}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@@ -17,7 +17,6 @@ import { AvatarEditor } from './AvatarEditor';
|
|||||||
import { AvatarPreview } from './AvatarPreview';
|
import { AvatarPreview } from './AvatarPreview';
|
||||||
import { Button, ButtonVariant } from './Button';
|
import { Button, ButtonVariant } from './Button';
|
||||||
import { ConfirmDiscardDialog } from './ConfirmDiscardDialog';
|
import { ConfirmDiscardDialog } from './ConfirmDiscardDialog';
|
||||||
import { Emoji } from './emoji/Emoji';
|
|
||||||
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
|
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
|
||||||
import { EmojiButton, EmojiButtonVariant } from './emoji/EmojiButton';
|
import { EmojiButton, EmojiButtonVariant } from './emoji/EmojiButton';
|
||||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||||
@@ -34,7 +33,7 @@ import type { UsernameLinkState } from '../state/ducks/usernameEnums';
|
|||||||
import { ToastType } from '../types/Toast';
|
import { ToastType } from '../types/Toast';
|
||||||
import type { ShowToastAction } from '../state/ducks/toast';
|
import type { ShowToastAction } from '../state/ducks/toast';
|
||||||
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
|
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
|
||||||
import { assertDev } from '../util/assert';
|
import { assertDev, strictAssert } from '../util/assert';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
import { ContextMenu } from './ContextMenu';
|
import { ContextMenu } from './ContextMenu';
|
||||||
@@ -48,6 +47,18 @@ import { UserText } from './UserText';
|
|||||||
import { Tooltip, TooltipPlacement } from './Tooltip';
|
import { Tooltip, TooltipPlacement } from './Tooltip';
|
||||||
import { offsetDistanceModifier } from '../util/popperUtil';
|
import { offsetDistanceModifier } from '../util/popperUtil';
|
||||||
import { useReducedMotion } from '../hooks/useReducedMotion';
|
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 {
|
export enum EditState {
|
||||||
None = 'None',
|
None = 'None',
|
||||||
@@ -89,12 +100,12 @@ export type PropsDataType = {
|
|||||||
usernameLinkColor?: number;
|
usernameLinkColor?: number;
|
||||||
usernameLink?: string;
|
usernameLink?: string;
|
||||||
usernameLinkCorrupted: boolean;
|
usernameLinkCorrupted: boolean;
|
||||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
} & Pick<EmojiButtonProps, 'recentEmojis' | 'emojiSkinToneDefault'>;
|
||||||
|
|
||||||
type PropsActionType = {
|
type PropsActionType = {
|
||||||
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
||||||
markCompletedUsernameLinkOnboarding: () => void;
|
markCompletedUsernameLinkOnboarding: () => void;
|
||||||
onSetSkinTone: (tone: number) => unknown;
|
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
|
||||||
replaceAvatar: ReplaceAvatarActionType;
|
replaceAvatar: ReplaceAvatarActionType;
|
||||||
saveAttachment: SaveAttachmentActionCreatorType;
|
saveAttachment: SaveAttachmentActionCreatorType;
|
||||||
saveAvatarToDisk: SaveAvatarToDiskActionType;
|
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({
|
export function ProfileEditor({
|
||||||
aboutEmoji,
|
aboutEmoji,
|
||||||
aboutText,
|
aboutText,
|
||||||
@@ -154,7 +179,7 @@ export function ProfileEditor({
|
|||||||
markCompletedUsernameLinkOnboarding,
|
markCompletedUsernameLinkOnboarding,
|
||||||
onEditStateChanged,
|
onEditStateChanged,
|
||||||
onProfileChanged,
|
onProfileChanged,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
openUsernameReservationModal,
|
openUsernameReservationModal,
|
||||||
profileAvatarUrl,
|
profileAvatarUrl,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
@@ -167,7 +192,7 @@ export function ProfileEditor({
|
|||||||
setUsernameEditState,
|
setUsernameEditState,
|
||||||
setUsernameLinkColor,
|
setUsernameLinkColor,
|
||||||
showToast,
|
showToast,
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
userAvatarData,
|
userAvatarData,
|
||||||
username,
|
username,
|
||||||
usernameCorrupted,
|
usernameCorrupted,
|
||||||
@@ -226,13 +251,13 @@ export function ProfileEditor({
|
|||||||
// To make EmojiButton re-render less often
|
// To make EmojiButton re-render less often
|
||||||
const setAboutEmoji = useCallback(
|
const setAboutEmoji = useCallback(
|
||||||
(ev: EmojiPickDataType) => {
|
(ev: EmojiPickDataType) => {
|
||||||
const emojiData = getEmojiData(ev.shortName, skinTone);
|
const emojiData = getEmojiData(ev.shortName, emojiSkinToneDefault);
|
||||||
setStagedProfile(profileData => ({
|
setStagedProfile(profileData => ({
|
||||||
...profileData,
|
...profileData,
|
||||||
aboutEmoji: unifiedToEmoji(emojiData.unified),
|
aboutEmoji: unifiedToEmoji(emojiData.unified),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[setStagedProfile, skinTone]
|
[setStagedProfile, emojiSkinToneDefault]
|
||||||
);
|
);
|
||||||
|
|
||||||
// To make AvatarEditor re-render less often
|
// To make AvatarEditor re-render less often
|
||||||
@@ -416,9 +441,9 @@ export function ProfileEditor({
|
|||||||
emoji={stagedProfile.aboutEmoji}
|
emoji={stagedProfile.aboutEmoji}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onPickEmoji={setAboutEmoji}
|
onPickEmoji={setAboutEmoji}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
recentEmojis={recentEmojis}
|
recentEmojis={recentEmojis}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -446,27 +471,44 @@ export function ProfileEditor({
|
|||||||
whenToShowRemainingCount={40}
|
whenToShowRemainingCount={40}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{defaultBios.map(defaultBio => (
|
{defaultBios.map(defaultBio => {
|
||||||
<PanelRow
|
strictAssert(
|
||||||
className="ProfileEditor__row"
|
isEmojiEnglishShortName(defaultBio.shortName),
|
||||||
key={defaultBio.shortName}
|
'Must be valid english short name'
|
||||||
icon={
|
);
|
||||||
<div className="ProfileEditor__icon--container">
|
const emojiParentKey = getEmojiParentKeyByEnglishShortName(
|
||||||
<Emoji shortName={defaultBio.shortName} size={24} />
|
defaultBio.shortName
|
||||||
</div>
|
);
|
||||||
}
|
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
|
||||||
label={defaultBio.i18nLabel}
|
emojiParentKey,
|
||||||
onClick={() => {
|
emojiSkinToneDefault
|
||||||
const emojiData = getEmojiData(defaultBio.shortName, skinTone);
|
);
|
||||||
|
|
||||||
setStagedProfile(profileData => ({
|
return (
|
||||||
...profileData,
|
<PanelRow
|
||||||
aboutEmoji: unifiedToEmoji(emojiData.unified),
|
className="ProfileEditor__row"
|
||||||
aboutText: defaultBio.i18nLabel,
|
key={defaultBio.shortName}
|
||||||
}));
|
icon={
|
||||||
}}
|
<div className="ProfileEditor__icon--container">
|
||||||
/>
|
<BioEmoji emoji={emojiVariant.key} />
|
||||||
))}
|
</div>
|
||||||
|
}
|
||||||
|
label={defaultBio.i18nLabel}
|
||||||
|
onClick={() => {
|
||||||
|
const emojiData = getEmojiData(
|
||||||
|
defaultBio.shortName,
|
||||||
|
emojiSkinToneDefault
|
||||||
|
);
|
||||||
|
|
||||||
|
setStagedProfile(profileData => ({
|
||||||
|
...profileData,
|
||||||
|
aboutEmoji: unifiedToEmoji(emojiData.unified),
|
||||||
|
aboutText: defaultBio.i18nLabel,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<Modal.ButtonFooter>
|
<Modal.ButtonFooter>
|
||||||
<Button
|
<Button
|
||||||
@@ -712,9 +754,11 @@ export function ProfileEditor({
|
|||||||
<PanelRow
|
<PanelRow
|
||||||
className="ProfileEditor__row"
|
className="ProfileEditor__row"
|
||||||
icon={
|
icon={
|
||||||
fullBio.aboutEmoji ? (
|
fullBio.aboutEmoji && isEmojiVariantValue(fullBio.aboutEmoji) ? (
|
||||||
<div className="ProfileEditor__icon--container">
|
<div className="ProfileEditor__icon--container">
|
||||||
<Emoji emoji={fullBio.aboutEmoji} size={24} />
|
<BioEmoji
|
||||||
|
emoji={getEmojiVariantKeyByValue(fullBio.aboutEmoji)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--bio" />
|
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--bio" />
|
||||||
|
@@ -38,7 +38,7 @@ export function ProfileEditorModal({
|
|||||||
initialEditState,
|
initialEditState,
|
||||||
markCompletedUsernameLinkOnboarding,
|
markCompletedUsernameLinkOnboarding,
|
||||||
myProfileChanged,
|
myProfileChanged,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
openUsernameReservationModal,
|
openUsernameReservationModal,
|
||||||
profileAvatarUrl,
|
profileAvatarUrl,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
@@ -50,7 +50,7 @@ export function ProfileEditorModal({
|
|||||||
setUsernameEditState,
|
setUsernameEditState,
|
||||||
setUsernameLinkColor,
|
setUsernameLinkColor,
|
||||||
showToast,
|
showToast,
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
toggleProfileEditor,
|
toggleProfileEditor,
|
||||||
toggleProfileEditorHasError,
|
toggleProfileEditorHasError,
|
||||||
userAvatarData,
|
userAvatarData,
|
||||||
@@ -115,7 +115,7 @@ export function ProfileEditorModal({
|
|||||||
setModalTitle(MODAL_TITLES_BY_EDIT_STATE[editState]);
|
setModalTitle(MODAL_TITLES_BY_EDIT_STATE[editState]);
|
||||||
}}
|
}}
|
||||||
onProfileChanged={myProfileChanged}
|
onProfileChanged={myProfileChanged}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
openUsernameReservationModal={openUsernameReservationModal}
|
openUsernameReservationModal={openUsernameReservationModal}
|
||||||
profileAvatarUrl={profileAvatarUrl}
|
profileAvatarUrl={profileAvatarUrl}
|
||||||
recentEmojis={recentEmojis}
|
recentEmojis={recentEmojis}
|
||||||
@@ -127,7 +127,7 @@ export function ProfileEditorModal({
|
|||||||
setUsernameEditState={setUsernameEditState}
|
setUsernameEditState={setUsernameEditState}
|
||||||
setUsernameLinkColor={setUsernameLinkColor}
|
setUsernameLinkColor={setUsernameLinkColor}
|
||||||
showToast={showToast}
|
showToast={showToast}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
toggleProfileEditor={toggleProfileEditor}
|
toggleProfileEditor={toggleProfileEditor}
|
||||||
userAvatarData={userAvatarData}
|
userAvatarData={userAvatarData}
|
||||||
username={username}
|
username={username}
|
||||||
|
@@ -5,8 +5,14 @@ import type { CSSProperties, ReactNode } from 'react';
|
|||||||
import React, { forwardRef } from 'react';
|
import React, { forwardRef } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { Emoji } from './emoji/Emoji';
|
|
||||||
import type { LocalizerType } from '../types/Util';
|
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 {
|
export enum ReactionPickerPickerStyle {
|
||||||
Picker,
|
Picker,
|
||||||
@@ -25,6 +31,13 @@ export const ReactionPickerPickerEmojiButton = React.forwardRef<
|
|||||||
{ emoji, onClick, isSelected, title },
|
{ emoji, onClick, isSelected, title },
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
|
strictAssert(
|
||||||
|
isEmojiVariantValue(emoji),
|
||||||
|
'Expected a valid emoji variant value'
|
||||||
|
);
|
||||||
|
const emojiVariantKey = getEmojiVariantKeyByValue(emoji);
|
||||||
|
const emojiVariant = getEmojiVariantByKey(emojiVariantKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@@ -13,6 +13,7 @@ import {
|
|||||||
getDefaultGroup,
|
getDefaultGroup,
|
||||||
} from '../test-both/helpers/getDefaultConversation';
|
} from '../test-both/helpers/getDefaultConversation';
|
||||||
import { getFakeDistributionListsWithMembers } from '../test-both/helpers/getFakeDistributionLists';
|
import { getFakeDistributionListsWithMembers } from '../test-both/helpers/getFakeDistributionLists';
|
||||||
|
import { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export default {
|
|||||||
onDistributionListCreated: undefined,
|
onDistributionListCreated: undefined,
|
||||||
onHideMyStoriesFrom: action('onHideMyStoriesFrom'),
|
onHideMyStoriesFrom: action('onHideMyStoriesFrom'),
|
||||||
onSend: action('onSend'),
|
onSend: action('onSend'),
|
||||||
onSetSkinTone: action('onSetSkinTone'),
|
onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'),
|
||||||
onUseEmoji: action('onUseEmoji'),
|
onUseEmoji: action('onUseEmoji'),
|
||||||
onViewersUpdated: action('onViewersUpdated'),
|
onViewersUpdated: action('onViewersUpdated'),
|
||||||
processAttachment: undefined,
|
processAttachment: undefined,
|
||||||
@@ -49,7 +50,7 @@ export default {
|
|||||||
'setMyStoriesToAllSignalConnections'
|
'setMyStoriesToAllSignalConnections'
|
||||||
),
|
),
|
||||||
signalConnections: Array.from(Array(42), getDefaultConversation),
|
signalConnections: Array.from(Array(42), getDefaultConversation),
|
||||||
skinTone: 0,
|
emojiSkinToneDefault: EmojiSkinTone.None,
|
||||||
toggleSignalConnectionsModal: action('toggleSignalConnectionsModal'),
|
toggleSignalConnectionsModal: action('toggleSignalConnectionsModal'),
|
||||||
},
|
},
|
||||||
} satisfies Meta<PropsType>;
|
} satisfies Meta<PropsType>;
|
||||||
|
@@ -92,7 +92,10 @@ export type PropsType = {
|
|||||||
> &
|
> &
|
||||||
Pick<
|
Pick<
|
||||||
TextStoryCreatorPropsType,
|
TextStoryCreatorPropsType,
|
||||||
'onUseEmoji' | 'skinTone' | 'onSetSkinTone' | 'recentEmojis'
|
| 'onUseEmoji'
|
||||||
|
| 'emojiSkinToneDefault'
|
||||||
|
| 'onEmojiSkinToneDefaultChange'
|
||||||
|
| 'recentEmojis'
|
||||||
> &
|
> &
|
||||||
Pick<
|
Pick<
|
||||||
MediaEditorPropsType,
|
MediaEditorPropsType,
|
||||||
@@ -130,7 +133,7 @@ export function StoryCreator({
|
|||||||
onRepliesNReactionsChanged,
|
onRepliesNReactionsChanged,
|
||||||
onSelectedStoryList,
|
onSelectedStoryList,
|
||||||
onSend,
|
onSend,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
onTextTooLong,
|
onTextTooLong,
|
||||||
onUseEmoji,
|
onUseEmoji,
|
||||||
onViewersUpdated,
|
onViewersUpdated,
|
||||||
@@ -142,7 +145,7 @@ export function StoryCreator({
|
|||||||
sendStoryModalOpenStateChanged,
|
sendStoryModalOpenStateChanged,
|
||||||
setMyStoriesToAllSignalConnections,
|
setMyStoriesToAllSignalConnections,
|
||||||
signalConnections,
|
signalConnections,
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
sortedGroupMembers,
|
sortedGroupMembers,
|
||||||
theme,
|
theme,
|
||||||
toggleGroupsForStorySend,
|
toggleGroupsForStorySend,
|
||||||
@@ -277,7 +280,7 @@ export function StoryCreator({
|
|||||||
ourConversationId={ourConversationId}
|
ourConversationId={ourConversationId}
|
||||||
platform={platform}
|
platform={platform}
|
||||||
recentStickers={recentStickers}
|
recentStickers={recentStickers}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
sortedGroupMembers={sortedGroupMembers}
|
sortedGroupMembers={sortedGroupMembers}
|
||||||
draftText={null}
|
draftText={null}
|
||||||
draftBodyRanges={null}
|
draftBodyRanges={null}
|
||||||
@@ -299,9 +302,9 @@ export function StoryCreator({
|
|||||||
setIsReadyToSend(true);
|
setIsReadyToSend(true);
|
||||||
}}
|
}}
|
||||||
onUseEmoji={onUseEmoji}
|
onUseEmoji={onUseEmoji}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
recentEmojis={recentEmojis}
|
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 { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||||
import { getFakeStoryView } from '../test-both/helpers/getFakeStory';
|
import { getFakeStoryView } from '../test-both/helpers/getFakeStory';
|
||||||
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants';
|
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants';
|
||||||
|
import { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export default {
|
|||||||
onHideStory: action('onHideStory'),
|
onHideStory: action('onHideStory'),
|
||||||
onReactToStory: action('onReactToStory'),
|
onReactToStory: action('onReactToStory'),
|
||||||
onReplyToStory: action('onReplyToStory'),
|
onReplyToStory: action('onReplyToStory'),
|
||||||
onSetSkinTone: action('onSetSkinTone'),
|
onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'),
|
||||||
onTextTooLong: action('onTextTooLong'),
|
onTextTooLong: action('onTextTooLong'),
|
||||||
onUseEmoji: action('onUseEmoji'),
|
onUseEmoji: action('onUseEmoji'),
|
||||||
onMediaPlaybackStart: action('onMediaPlaybackStart'),
|
onMediaPlaybackStart: action('onMediaPlaybackStart'),
|
||||||
@@ -52,7 +53,7 @@ export default {
|
|||||||
renderEmojiPicker: () => <>EmojiPicker</>,
|
renderEmojiPicker: () => <>EmojiPicker</>,
|
||||||
retryMessageSend: action('retryMessageSend'),
|
retryMessageSend: action('retryMessageSend'),
|
||||||
showToast: action('showToast'),
|
showToast: action('showToast'),
|
||||||
skinTone: 0,
|
emojiSkinToneDefault: EmojiSkinTone.None,
|
||||||
story: getFakeStoryView(),
|
story: getFakeStoryView(),
|
||||||
storyViewMode: StoryViewModeType.All,
|
storyViewMode: StoryViewModeType.All,
|
||||||
viewStory: action('viewStory'),
|
viewStory: action('viewStory'),
|
||||||
|
@@ -54,6 +54,7 @@ import { RenderLocation } from './conversation/MessageTextRenderer';
|
|||||||
import { arrow } from '../util/keyboard';
|
import { arrow } from '../util/keyboard';
|
||||||
import { useElementId } from '../hooks/useUniqueId';
|
import { useElementId } from '../hooks/useUniqueId';
|
||||||
import { StoryProgressSegment } from './StoryProgressSegment';
|
import { StoryProgressSegment } from './StoryProgressSegment';
|
||||||
|
import type { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
function renderStrong(parts: Array<JSX.Element | string>) {
|
function renderStrong(parts: Array<JSX.Element | string>) {
|
||||||
return <strong>{parts}</strong>;
|
return <strong>{parts}</strong>;
|
||||||
@@ -92,7 +93,7 @@ export type PropsType = {
|
|||||||
numStories: number;
|
numStories: number;
|
||||||
onGoToConversation: (conversationId: string) => unknown;
|
onGoToConversation: (conversationId: string) => unknown;
|
||||||
onHideStory: (conversationId: string) => unknown;
|
onHideStory: (conversationId: string) => unknown;
|
||||||
onSetSkinTone: (tone: number) => unknown;
|
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => unknown;
|
||||||
onTextTooLong: () => unknown;
|
onTextTooLong: () => unknown;
|
||||||
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
|
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
|
||||||
onReplyToStory: (
|
onReplyToStory: (
|
||||||
@@ -115,7 +116,7 @@ export type PropsType = {
|
|||||||
setHasAllStoriesUnmuted: (isUnmuted: boolean) => unknown;
|
setHasAllStoriesUnmuted: (isUnmuted: boolean) => unknown;
|
||||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||||
showToast: ShowToastAction;
|
showToast: ShowToastAction;
|
||||||
skinTone?: number;
|
emojiSkinToneDefault: EmojiSkinTone;
|
||||||
story: StoryViewType;
|
story: StoryViewType;
|
||||||
storyViewMode: StoryViewModeType;
|
storyViewMode: StoryViewModeType;
|
||||||
viewStory: ViewStoryActionCreatorType;
|
viewStory: ViewStoryActionCreatorType;
|
||||||
@@ -156,7 +157,7 @@ export function StoryViewer({
|
|||||||
onHideStory,
|
onHideStory,
|
||||||
onReactToStory,
|
onReactToStory,
|
||||||
onReplyToStory,
|
onReplyToStory,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
onTextTooLong,
|
onTextTooLong,
|
||||||
onUseEmoji,
|
onUseEmoji,
|
||||||
onMediaPlaybackStart,
|
onMediaPlaybackStart,
|
||||||
@@ -172,7 +173,7 @@ export function StoryViewer({
|
|||||||
setHasAllStoriesUnmuted,
|
setHasAllStoriesUnmuted,
|
||||||
showContactModal,
|
showContactModal,
|
||||||
showToast,
|
showToast,
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
story,
|
story,
|
||||||
storyViewMode,
|
storyViewMode,
|
||||||
viewStory,
|
viewStory,
|
||||||
@@ -977,7 +978,7 @@ export function StoryViewer({
|
|||||||
}
|
}
|
||||||
onReplyToStory(message, replyBodyRanges, replyTimestamp, story);
|
onReplyToStory(message, replyBodyRanges, replyTimestamp, story);
|
||||||
}}
|
}}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
onTextTooLong={onTextTooLong}
|
onTextTooLong={onTextTooLong}
|
||||||
onUseEmoji={onUseEmoji}
|
onUseEmoji={onUseEmoji}
|
||||||
ourConversationId={ourConversationId}
|
ourConversationId={ourConversationId}
|
||||||
@@ -986,7 +987,7 @@ export function StoryViewer({
|
|||||||
renderEmojiPicker={renderEmojiPicker}
|
renderEmojiPicker={renderEmojiPicker}
|
||||||
replies={replies}
|
replies={replies}
|
||||||
showContactModal={showContactModal}
|
showContactModal={showContactModal}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
sortedGroupMembers={group?.sortedGroupMembers}
|
sortedGroupMembers={group?.sortedGroupMembers}
|
||||||
views={views}
|
views={views}
|
||||||
viewTarget={currentViewTarget}
|
viewTarget={currentViewTarget}
|
||||||
|
@@ -36,7 +36,7 @@ export default {
|
|||||||
i18n,
|
i18n,
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
onClose: action('onClose'),
|
onClose: action('onClose'),
|
||||||
onSetSkinTone: action('onSetSkinTone'),
|
onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'),
|
||||||
onReact: action('onReact'),
|
onReact: action('onReact'),
|
||||||
onReply: action('onReply'),
|
onReply: action('onReply'),
|
||||||
onTextTooLong: action('onTextTooLong'),
|
onTextTooLong: action('onTextTooLong'),
|
||||||
|
@@ -37,6 +37,7 @@ import { getAvatarColor } from '../types/Colors';
|
|||||||
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
|
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
|
||||||
import { ContextMenu } from './ContextMenu';
|
import { ContextMenu } from './ContextMenu';
|
||||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
|
import type { EmojiSkinTone } from './fun/data/emojis';
|
||||||
|
|
||||||
// Menu is disabled so these actions are inaccessible. We also don't support
|
// Menu is disabled so these actions are inaccessible. We also don't support
|
||||||
// link previews, tap to view messages, attachments, or gifts. Just regular
|
// link previews, tap to view messages, attachments, or gifts. Just regular
|
||||||
@@ -107,7 +108,7 @@ export type PropsType = {
|
|||||||
bodyRanges: DraftBodyRanges,
|
bodyRanges: DraftBodyRanges,
|
||||||
timestamp: number
|
timestamp: number
|
||||||
) => unknown;
|
) => unknown;
|
||||||
onSetSkinTone: (tone: number) => unknown;
|
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
|
||||||
onTextTooLong: () => unknown;
|
onTextTooLong: () => unknown;
|
||||||
onUseEmoji: (_: EmojiPickDataType) => unknown;
|
onUseEmoji: (_: EmojiPickDataType) => unknown;
|
||||||
ourConversationId: string | undefined;
|
ourConversationId: string | undefined;
|
||||||
@@ -116,7 +117,7 @@ export type PropsType = {
|
|||||||
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
|
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
|
||||||
replies: ReadonlyArray<ReplyType>;
|
replies: ReadonlyArray<ReplyType>;
|
||||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||||
skinTone?: number;
|
emojiSkinToneDefault: EmojiSkinTone;
|
||||||
sortedGroupMembers?: ReadonlyArray<ConversationType>;
|
sortedGroupMembers?: ReadonlyArray<ConversationType>;
|
||||||
views: ReadonlyArray<StorySendStateType>;
|
views: ReadonlyArray<StorySendStateType>;
|
||||||
viewTarget: StoryViewTargetType;
|
viewTarget: StoryViewTargetType;
|
||||||
@@ -139,7 +140,7 @@ export function StoryViewsNRepliesModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onReact,
|
onReact,
|
||||||
onReply,
|
onReply,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
onTextTooLong,
|
onTextTooLong,
|
||||||
onUseEmoji,
|
onUseEmoji,
|
||||||
ourConversationId,
|
ourConversationId,
|
||||||
@@ -148,7 +149,7 @@ export function StoryViewsNRepliesModal({
|
|||||||
renderEmojiPicker,
|
renderEmojiPicker,
|
||||||
replies,
|
replies,
|
||||||
showContactModal,
|
showContactModal,
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
sortedGroupMembers,
|
sortedGroupMembers,
|
||||||
viewTarget,
|
viewTarget,
|
||||||
views,
|
views,
|
||||||
@@ -238,7 +239,7 @@ export function StoryViewsNRepliesModal({
|
|||||||
}
|
}
|
||||||
onReact(emoji);
|
onReact(emoji);
|
||||||
}}
|
}}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
preferredReactionEmoji={preferredReactionEmoji}
|
preferredReactionEmoji={preferredReactionEmoji}
|
||||||
renderEmojiPicker={renderEmojiPicker}
|
renderEmojiPicker={renderEmojiPicker}
|
||||||
/>
|
/>
|
||||||
@@ -274,7 +275,7 @@ export function StoryViewsNRepliesModal({
|
|||||||
platform={platform}
|
platform={platform}
|
||||||
quotedMessageId={null}
|
quotedMessageId={null}
|
||||||
sendCounter={0}
|
sendCounter={0}
|
||||||
skinTone={skinTone ?? null}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
sortedGroupMembers={sortedGroupMembers ?? null}
|
sortedGroupMembers={sortedGroupMembers ?? null}
|
||||||
theme={ThemeType.dark}
|
theme={ThemeType.dark}
|
||||||
conversationId={null}
|
conversationId={null}
|
||||||
@@ -290,8 +291,8 @@ export function StoryViewsNRepliesModal({
|
|||||||
onPickEmoji={insertEmoji}
|
onPickEmoji={insertEmoji}
|
||||||
onClose={focusComposer}
|
onClose={focusComposer}
|
||||||
recentEmojis={recentEmojis}
|
recentEmojis={recentEmojis}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
/>
|
/>
|
||||||
</CompositionInput>
|
</CompositionInput>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -47,7 +47,10 @@ export type PropsType = {
|
|||||||
onClose: () => unknown;
|
onClose: () => unknown;
|
||||||
onDone: (textAttachment: TextAttachmentType) => unknown;
|
onDone: (textAttachment: TextAttachmentType) => unknown;
|
||||||
onUseEmoji: (_: EmojiPickDataType) => unknown;
|
onUseEmoji: (_: EmojiPickDataType) => unknown;
|
||||||
} & Pick<EmojiButtonPropsType, 'onSetSkinTone' | 'recentEmojis' | 'skinTone'>;
|
} & Pick<
|
||||||
|
EmojiButtonPropsType,
|
||||||
|
'onEmojiSkinToneDefaultChange' | 'recentEmojis' | 'emojiSkinToneDefault'
|
||||||
|
>;
|
||||||
|
|
||||||
enum LinkPreviewApplied {
|
enum LinkPreviewApplied {
|
||||||
None = 'None',
|
None = 'None',
|
||||||
@@ -138,10 +141,10 @@ export function TextStoryCreator({
|
|||||||
linkPreview,
|
linkPreview,
|
||||||
onClose,
|
onClose,
|
||||||
onDone,
|
onDone,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
onUseEmoji,
|
onUseEmoji,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const [showConfirmDiscardModal, setShowConfirmDiscardModal] = useState(false);
|
const [showConfirmDiscardModal, setShowConfirmDiscardModal] = useState(false);
|
||||||
|
|
||||||
@@ -452,8 +455,8 @@ export function TextStoryCreator({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
recentEmojis={recentEmojis}
|
recentEmojis={recentEmojis}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
@@ -4,10 +4,14 @@ import React, { useMemo } from 'react';
|
|||||||
import { Emojify } from './conversation/Emojify';
|
import { Emojify } from './conversation/Emojify';
|
||||||
import { bidiIsolate } from '../util/unicodeBidi';
|
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(() => {
|
const normalizedText = useMemo(() => {
|
||||||
return bidiIsolate(text);
|
return bidiIsolate(props.text);
|
||||||
}, [text]);
|
}, [props.text]);
|
||||||
return (
|
return (
|
||||||
<span dir="auto">
|
<span dir="auto">
|
||||||
<Emojify text={normalizedText} />
|
<Emojify text={normalizedText} />
|
||||||
|
@@ -12,7 +12,7 @@ export default {
|
|||||||
|
|
||||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
renderNonEmoji: overrideProps.renderNonEmoji,
|
renderNonEmoji: overrideProps.renderNonEmoji,
|
||||||
sizeClass: overrideProps.sizeClass,
|
fontSizeOverride: overrideProps.fontSizeOverride,
|
||||||
text: overrideProps.text || '',
|
text: overrideProps.text || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export function SkinColorModifier(): JSX.Element {
|
|||||||
export function Jumbo(): JSX.Element {
|
export function Jumbo(): JSX.Element {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
text: '😹😹😹',
|
text: '😹😹😹',
|
||||||
sizeClass: 'max',
|
fontSizeOverride: 56,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <Emojify {...props} />;
|
return <Emojify {...props} />;
|
||||||
@@ -44,7 +44,7 @@ export function Jumbo(): JSX.Element {
|
|||||||
export function ExtraLarge(): JSX.Element {
|
export function ExtraLarge(): JSX.Element {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
text: '😹😹😹',
|
text: '😹😹😹',
|
||||||
sizeClass: 'extra-large',
|
fontSizeOverride: 48,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <Emojify {...props} />;
|
return <Emojify {...props} />;
|
||||||
@@ -53,7 +53,7 @@ export function ExtraLarge(): JSX.Element {
|
|||||||
export function Large(): JSX.Element {
|
export function Large(): JSX.Element {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
text: '😹😹😹',
|
text: '😹😹😹',
|
||||||
sizeClass: 'large',
|
fontSizeOverride: 40,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <Emojify {...props} />;
|
return <Emojify {...props} />;
|
||||||
@@ -62,7 +62,7 @@ export function Large(): JSX.Element {
|
|||||||
export function Medium(): JSX.Element {
|
export function Medium(): JSX.Element {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
text: '😹😹😹',
|
text: '😹😹😹',
|
||||||
sizeClass: 'medium',
|
fontSizeOverride: 36,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <Emojify {...props} />;
|
return <Emojify {...props} />;
|
||||||
@@ -71,7 +71,7 @@ export function Medium(): JSX.Element {
|
|||||||
export function Small(): JSX.Element {
|
export function Small(): JSX.Element {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
text: '😹😹😹',
|
text: '😹😹😹',
|
||||||
sizeClass: 'small',
|
fontSizeOverride: 32,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <Emojify {...props} />;
|
return <Emojify {...props} />;
|
||||||
|
@@ -1,83 +1,59 @@
|
|||||||
// Copyright 2018 Signal Messenger, LLC
|
// Copyright 2018 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import type { RenderTextCallbackType } from '../../types/Util';
|
import type { RenderTextCallbackType } from '../../types/Util';
|
||||||
import { splitByEmoji } from '../../util/emoji';
|
import { splitByEmoji } from '../../util/emoji';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import type { SizeClassType } from '../emoji/lib';
|
import { FunInlineEmoji } from '../fun/FunEmoji';
|
||||||
import { emojiToImage } from '../emoji/lib';
|
import {
|
||||||
|
getEmojiParentByKey,
|
||||||
const JUMBO_SIZES = new Set<SizeClassType>(['large', 'extra-large', 'max']);
|
getEmojiParentKeyByVariantKey,
|
||||||
|
getEmojiVariantByKey,
|
||||||
// Some of this logic taken from emoji-js/replacement
|
getEmojiVariantKeyByValue,
|
||||||
// the DOM structure for this getImageTag should match the other emoji implementations:
|
isEmojiVariantValue,
|
||||||
// ts/components/emoji/Emoji.tsx
|
} from '../fun/data/emojis';
|
||||||
// ts/quill/emoji/blot.tsx
|
import { strictAssert } from '../../util/assert';
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
|
fontSizeOverride?: number | null;
|
||||||
|
text: string;
|
||||||
/** When behind a spoiler, this emoji needs to be visibility: hidden */
|
/** When behind a spoiler, this emoji needs to be visibility: hidden */
|
||||||
isInvisible?: boolean;
|
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>. */
|
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
|
||||||
renderNonEmoji?: RenderTextCallbackType;
|
renderNonEmoji?: RenderTextCallbackType;
|
||||||
text: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
|
const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
|
||||||
|
|
||||||
export function Emojify({
|
export function Emojify({
|
||||||
isInvisible,
|
fontSizeOverride,
|
||||||
renderNonEmoji = defaultRenderNonEmoji,
|
|
||||||
sizeClass,
|
|
||||||
text,
|
text,
|
||||||
|
renderNonEmoji = defaultRenderNonEmoji,
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{splitByEmoji(text).map(({ type, value: match }, index) => {
|
{splitByEmoji(text).map(({ type, value: match }, index) => {
|
||||||
if (type === 'emoji') {
|
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') {
|
if (type === 'text') {
|
||||||
|
@@ -43,7 +43,6 @@ import { Quote } from './Quote';
|
|||||||
import { EmbeddedContact } from './EmbeddedContact';
|
import { EmbeddedContact } from './EmbeddedContact';
|
||||||
import type { OwnProps as ReactionViewerProps } from './ReactionViewer';
|
import type { OwnProps as ReactionViewerProps } from './ReactionViewer';
|
||||||
import { ReactionViewer } from './ReactionViewer';
|
import { ReactionViewer } from './ReactionViewer';
|
||||||
import { Emoji } from '../emoji/Emoji';
|
|
||||||
import { LinkPreviewDate } from './LinkPreviewDate';
|
import { LinkPreviewDate } from './LinkPreviewDate';
|
||||||
import type { LinkPreviewForUIType } from '../../types/message/LinkPreviews';
|
import type { LinkPreviewForUIType } from '../../types/message/LinkPreviews';
|
||||||
import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage';
|
import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage';
|
||||||
@@ -105,11 +104,19 @@ import { getKeyFromCallLink } from '../../util/callLinks';
|
|||||||
import { InAnotherCallTooltip } from './InAnotherCallTooltip';
|
import { InAnotherCallTooltip } from './InAnotherCallTooltip';
|
||||||
import { formatFileSize } from '../../util/formatFileSize';
|
import { formatFileSize } from '../../util/formatFileSize';
|
||||||
import { AttachmentNotAvailableModalType } from '../AttachmentNotAvailableModal';
|
import { AttachmentNotAvailableModalType } from '../AttachmentNotAvailableModal';
|
||||||
import { assertDev } from '../../util/assert';
|
import { assertDev, strictAssert } from '../../util/assert';
|
||||||
import { AttachmentStatusIcon } from './AttachmentStatusIcon';
|
import { AttachmentStatusIcon } from './AttachmentStatusIcon';
|
||||||
import { isFileDangerous } from '../../util/isFileDangerous';
|
import { isFileDangerous } from '../../util/isFileDangerous';
|
||||||
import { TapToViewNotAvailableType } from '../TapToViewNotAvailableModal';
|
import { TapToViewNotAvailableType } from '../TapToViewNotAvailableModal';
|
||||||
import type { DataPropsType as TapToViewNotAvailablePropsType } 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_TIMESTAMP_SIZE = 16;
|
||||||
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
|
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
|
||||||
@@ -224,6 +231,26 @@ export type GiftBadgeType =
|
|||||||
state: GiftBadgeStates.Failed;
|
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 = {
|
export type PropsData = {
|
||||||
id: string;
|
id: string;
|
||||||
renderingContext: string;
|
renderingContext: string;
|
||||||
@@ -2885,7 +2912,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Emoji size={16} emoji={re.emoji} />
|
<ReactionEmoji emojiVariantValue={re.emoji} />
|
||||||
{re.count > 1 ? (
|
{re.count > 1 ? (
|
||||||
<span
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
@@ -68,7 +68,7 @@ export function MessageBody({
|
|||||||
? `${text}...`
|
? `${text}...`
|
||||||
: text;
|
: text;
|
||||||
|
|
||||||
const sizeClass = disableJumbomoji ? undefined : getSizeClass(text);
|
const sizeClass = disableJumbomoji ? null : getSizeClass(text);
|
||||||
|
|
||||||
let endNotification: React.ReactNode;
|
let endNotification: React.ReactNode;
|
||||||
if (onIncreaseTextLength) {
|
if (onIncreaseTextLength) {
|
||||||
@@ -150,7 +150,7 @@ export function MessageBody({
|
|||||||
bodyRanges={bodyRanges ?? []}
|
bodyRanges={bodyRanges ?? []}
|
||||||
direction={direction}
|
direction={direction}
|
||||||
disableLinks={shouldDisableLinks}
|
disableLinks={shouldDisableLinks}
|
||||||
emojiSizeClass={sizeClass}
|
jumboEmojiSize={sizeClass}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
isSpoilerExpanded={isSpoilerExpanded}
|
isSpoilerExpanded={isSpoilerExpanded}
|
||||||
messageText={textWithSuffix}
|
messageText={textWithSuffix}
|
||||||
|
@@ -24,8 +24,8 @@ import { AtMention } from './AtMention';
|
|||||||
import { isLinkSneaky } from '../../types/LinkPreview';
|
import { isLinkSneaky } from '../../types/LinkPreview';
|
||||||
import { Emojify } from './Emojify';
|
import { Emojify } from './Emojify';
|
||||||
import { AddNewLines } from './AddNewLines';
|
import { AddNewLines } from './AddNewLines';
|
||||||
import type { SizeClassType } from '../emoji/lib';
|
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
|
import type { FunJumboEmojiSize } from '../fun/FunEmoji';
|
||||||
|
|
||||||
const EMOJI_REGEXP = emojiRegex();
|
const EMOJI_REGEXP = emojiRegex();
|
||||||
export enum RenderLocation {
|
export enum RenderLocation {
|
||||||
@@ -41,7 +41,7 @@ type Props = {
|
|||||||
bodyRanges: BodyRangesForDisplayType;
|
bodyRanges: BodyRangesForDisplayType;
|
||||||
direction: 'incoming' | 'outgoing' | undefined;
|
direction: 'incoming' | 'outgoing' | undefined;
|
||||||
disableLinks: boolean;
|
disableLinks: boolean;
|
||||||
emojiSizeClass: SizeClassType | undefined;
|
jumboEmojiSize: FunJumboEmojiSize | null;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
isSpoilerExpanded: Record<number, boolean>;
|
isSpoilerExpanded: Record<number, boolean>;
|
||||||
messageText: string;
|
messageText: string;
|
||||||
@@ -56,7 +56,7 @@ export function MessageTextRenderer({
|
|||||||
bodyRanges,
|
bodyRanges,
|
||||||
direction,
|
direction,
|
||||||
disableLinks,
|
disableLinks,
|
||||||
emojiSizeClass,
|
jumboEmojiSize,
|
||||||
i18n,
|
i18n,
|
||||||
isSpoilerExpanded,
|
isSpoilerExpanded,
|
||||||
messageText,
|
messageText,
|
||||||
@@ -112,7 +112,7 @@ export function MessageTextRenderer({
|
|||||||
renderNode({
|
renderNode({
|
||||||
direction,
|
direction,
|
||||||
disableLinks,
|
disableLinks,
|
||||||
emojiSizeClass,
|
jumboEmojiSize,
|
||||||
i18n,
|
i18n,
|
||||||
isInvisible: false,
|
isInvisible: false,
|
||||||
isSpoilerExpanded,
|
isSpoilerExpanded,
|
||||||
@@ -129,7 +129,7 @@ export function MessageTextRenderer({
|
|||||||
function renderNode({
|
function renderNode({
|
||||||
direction,
|
direction,
|
||||||
disableLinks,
|
disableLinks,
|
||||||
emojiSizeClass,
|
jumboEmojiSize,
|
||||||
i18n,
|
i18n,
|
||||||
isInvisible,
|
isInvisible,
|
||||||
isSpoilerExpanded,
|
isSpoilerExpanded,
|
||||||
@@ -140,7 +140,7 @@ function renderNode({
|
|||||||
}: {
|
}: {
|
||||||
direction: 'incoming' | 'outgoing' | undefined;
|
direction: 'incoming' | 'outgoing' | undefined;
|
||||||
disableLinks: boolean;
|
disableLinks: boolean;
|
||||||
emojiSizeClass: SizeClassType | undefined;
|
jumboEmojiSize: FunJumboEmojiSize | null;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
isInvisible: boolean;
|
isInvisible: boolean;
|
||||||
isSpoilerExpanded: Record<number, boolean>;
|
isSpoilerExpanded: Record<number, boolean>;
|
||||||
@@ -159,7 +159,7 @@ function renderNode({
|
|||||||
renderNode({
|
renderNode({
|
||||||
direction,
|
direction,
|
||||||
disableLinks,
|
disableLinks,
|
||||||
emojiSizeClass,
|
jumboEmojiSize,
|
||||||
i18n,
|
i18n,
|
||||||
isInvisible: isSpoilerHidden,
|
isInvisible: isSpoilerHidden,
|
||||||
isSpoilerExpanded,
|
isSpoilerExpanded,
|
||||||
@@ -236,7 +236,7 @@ function renderNode({
|
|||||||
let content = renderMentions({
|
let content = renderMentions({
|
||||||
direction,
|
direction,
|
||||||
disableLinks,
|
disableLinks,
|
||||||
emojiSizeClass,
|
jumboEmojiSize,
|
||||||
isInvisible,
|
isInvisible,
|
||||||
mentions: node.mentions,
|
mentions: node.mentions,
|
||||||
onMentionTrigger,
|
onMentionTrigger,
|
||||||
@@ -284,7 +284,7 @@ function renderNode({
|
|||||||
function renderMentions({
|
function renderMentions({
|
||||||
direction,
|
direction,
|
||||||
disableLinks,
|
disableLinks,
|
||||||
emojiSizeClass,
|
jumboEmojiSize,
|
||||||
isInvisible,
|
isInvisible,
|
||||||
mentions,
|
mentions,
|
||||||
node,
|
node,
|
||||||
@@ -292,7 +292,7 @@ function renderMentions({
|
|||||||
}: {
|
}: {
|
||||||
direction: 'incoming' | 'outgoing' | undefined;
|
direction: 'incoming' | 'outgoing' | undefined;
|
||||||
disableLinks: boolean;
|
disableLinks: boolean;
|
||||||
emojiSizeClass: SizeClassType | undefined;
|
jumboEmojiSize: FunJumboEmojiSize | null;
|
||||||
isInvisible: boolean;
|
isInvisible: boolean;
|
||||||
mentions: ReadonlyArray<HydratedBodyRangeMention>;
|
mentions: ReadonlyArray<HydratedBodyRangeMention>;
|
||||||
node: DisplayNode;
|
node: DisplayNode;
|
||||||
@@ -310,7 +310,7 @@ function renderMentions({
|
|||||||
renderText({
|
renderText({
|
||||||
isInvisible,
|
isInvisible,
|
||||||
key: result.length.toString(),
|
key: result.length.toString(),
|
||||||
emojiSizeClass,
|
jumboEmojiSize,
|
||||||
text: text.slice(offset, mention.start),
|
text: text.slice(offset, mention.start),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -337,7 +337,7 @@ function renderMentions({
|
|||||||
renderText({
|
renderText({
|
||||||
isInvisible,
|
isInvisible,
|
||||||
key: result.length.toString(),
|
key: result.length.toString(),
|
||||||
emojiSizeClass,
|
jumboEmojiSize,
|
||||||
text: text.slice(offset, text.length),
|
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 */
|
/** Render text that does not contain body ranges or is in between body ranges */
|
||||||
function renderText({
|
function renderText({
|
||||||
text,
|
text,
|
||||||
emojiSizeClass,
|
jumboEmojiSize,
|
||||||
isInvisible,
|
isInvisible,
|
||||||
key,
|
key,
|
||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
emojiSizeClass: SizeClassType | undefined;
|
jumboEmojiSize: FunJumboEmojiSize | null;
|
||||||
isInvisible: boolean;
|
isInvisible: boolean;
|
||||||
key: string;
|
key: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -417,7 +417,7 @@ function renderText({
|
|||||||
renderNonEmoji={({ text: innerText, key: innerKey }) => (
|
renderNonEmoji={({ text: innerText, key: innerKey }) => (
|
||||||
<AddNewLines key={innerKey} text={innerText} />
|
<AddNewLines key={innerKey} text={innerText} />
|
||||||
)}
|
)}
|
||||||
sizeClass={emojiSizeClass}
|
fontSizeOverride={jumboEmojiSize}
|
||||||
text={text}
|
text={text}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@@ -8,22 +8,23 @@ import type { Props as ReactionPickerProps } from './ReactionPicker';
|
|||||||
import { ReactionPicker } from './ReactionPicker';
|
import { ReactionPicker } from './ReactionPicker';
|
||||||
import { EmojiPicker } from '../emoji/EmojiPicker';
|
import { EmojiPicker } from '../emoji/EmojiPicker';
|
||||||
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../../reactions/constants';
|
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../../reactions/constants';
|
||||||
|
import { EmojiSkinTone } from '../fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
const renderEmojiPicker: ReactionPickerProps['renderEmojiPicker'] = ({
|
const renderEmojiPicker: ReactionPickerProps['renderEmojiPicker'] = ({
|
||||||
onClose,
|
onClose,
|
||||||
onPickEmoji,
|
onPickEmoji,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
ref,
|
ref,
|
||||||
}) => (
|
}) => (
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
skinTone={0}
|
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onPickEmoji={onPickEmoji}
|
onPickEmoji={onPickEmoji}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
wasInvokedFromKeyboard={false}
|
wasInvokedFromKeyboard={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -37,7 +38,7 @@ export function Base(): JSX.Element {
|
|||||||
<ReactionPicker
|
<ReactionPicker
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onPick={action('onPick')}
|
onPick={action('onPick')}
|
||||||
onSetSkinTone={action('onSetSkinTone')}
|
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
|
||||||
openCustomizePreferredReactionsModal={action(
|
openCustomizePreferredReactionsModal={action(
|
||||||
'openCustomizePreferredReactionsModal'
|
'openCustomizePreferredReactionsModal'
|
||||||
)}
|
)}
|
||||||
@@ -56,7 +57,9 @@ export function SelectedReaction(): JSX.Element {
|
|||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
selected={e}
|
selected={e}
|
||||||
onPick={action('onPick')}
|
onPick={action('onPick')}
|
||||||
onSetSkinTone={action('onSetSkinTone')}
|
onEmojiSkinToneDefaultChange={action(
|
||||||
|
'onEmojiSkinToneDefaultChange'
|
||||||
|
)}
|
||||||
openCustomizePreferredReactionsModal={action(
|
openCustomizePreferredReactionsModal={action(
|
||||||
'openCustomizePreferredReactionsModal'
|
'openCustomizePreferredReactionsModal'
|
||||||
)}
|
)}
|
||||||
|
@@ -12,11 +12,12 @@ import {
|
|||||||
ReactionPickerPickerMoreButton,
|
ReactionPickerPickerMoreButton,
|
||||||
ReactionPickerPickerStyle,
|
ReactionPickerPickerStyle,
|
||||||
} from '../ReactionPickerPicker';
|
} from '../ReactionPickerPicker';
|
||||||
|
import type { EmojiSkinTone } from '../fun/data/emojis';
|
||||||
|
|
||||||
export type RenderEmojiPickerProps = Pick<Props, 'onClose' | 'style'> &
|
export type RenderEmojiPickerProps = Pick<Props, 'onClose' | 'style'> &
|
||||||
Pick<
|
Pick<
|
||||||
EmojiPickerProps,
|
EmojiPickerProps,
|
||||||
'onClickSettings' | 'onPickEmoji' | 'onSetSkinTone'
|
'onClickSettings' | 'onPickEmoji' | 'onEmojiSkinToneDefaultChange'
|
||||||
> & {
|
> & {
|
||||||
ref: React.Ref<HTMLDivElement>;
|
ref: React.Ref<HTMLDivElement>;
|
||||||
};
|
};
|
||||||
@@ -26,7 +27,7 @@ export type OwnProps = {
|
|||||||
selected?: string;
|
selected?: string;
|
||||||
onClose?: () => unknown;
|
onClose?: () => unknown;
|
||||||
onPick: (emoji: string) => unknown;
|
onPick: (emoji: string) => unknown;
|
||||||
onSetSkinTone: (tone: number) => unknown;
|
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => unknown;
|
||||||
openCustomizePreferredReactionsModal?: () => unknown;
|
openCustomizePreferredReactionsModal?: () => unknown;
|
||||||
preferredReactionEmoji: ReadonlyArray<string>;
|
preferredReactionEmoji: ReadonlyArray<string>;
|
||||||
renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement;
|
renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement;
|
||||||
@@ -40,7 +41,7 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
|
|||||||
i18n,
|
i18n,
|
||||||
onClose,
|
onClose,
|
||||||
onPick,
|
onPick,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
openCustomizePreferredReactionsModal,
|
openCustomizePreferredReactionsModal,
|
||||||
preferredReactionEmoji,
|
preferredReactionEmoji,
|
||||||
renderEmojiPicker,
|
renderEmojiPicker,
|
||||||
@@ -82,7 +83,7 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
|
|||||||
onClickSettings: openCustomizePreferredReactionsModal,
|
onClickSettings: openCustomizePreferredReactionsModal,
|
||||||
onClose,
|
onClose,
|
||||||
onPickEmoji,
|
onPickEmoji,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
ref,
|
ref,
|
||||||
style,
|
style,
|
||||||
});
|
});
|
||||||
|
@@ -7,7 +7,6 @@ import classNames from 'classnames';
|
|||||||
import { ContactName } from './ContactName';
|
import { ContactName } from './ContactName';
|
||||||
import type { Props as AvatarProps } from '../Avatar';
|
import type { Props as AvatarProps } from '../Avatar';
|
||||||
import { Avatar } from '../Avatar';
|
import { Avatar } from '../Avatar';
|
||||||
import { Emoji } from '../emoji/Emoji';
|
|
||||||
import { useRestoreFocus } from '../../hooks/useRestoreFocus';
|
import { useRestoreFocus } from '../../hooks/useRestoreFocus';
|
||||||
import type { ConversationType } from '../../state/ducks/conversations';
|
import type { ConversationType } from '../../state/ducks/conversations';
|
||||||
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
|
||||||
@@ -15,6 +14,15 @@ import type { EmojiData } from '../emoji/lib';
|
|||||||
import { emojiToData } from '../emoji/lib';
|
import { emojiToData } from '../emoji/lib';
|
||||||
import { useEscapeHandling } from '../../hooks/useEscapeHandling';
|
import { useEscapeHandling } from '../../hooks/useEscapeHandling';
|
||||||
import type { ThemeType } from '../../types/Util';
|
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 = {
|
export type Reaction = {
|
||||||
emoji: string;
|
emoji: string;
|
||||||
@@ -65,6 +73,30 @@ type ReactionCategory = {
|
|||||||
|
|
||||||
type ReactionWithEmojiData = Reaction & EmojiData;
|
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>(
|
export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
|
||||||
function ReactionViewerInner(
|
function ReactionViewerInner(
|
||||||
{
|
{
|
||||||
@@ -207,7 +239,7 @@ export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Emoji size={18} emoji={emoji} />
|
<ReactionViewerEmoji emojiVariantValue={emoji} />
|
||||||
<span className="module-reaction-viewer__header__button__count">
|
<span className="module-reaction-viewer__header__button__count">
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
@@ -251,7 +283,7 @@ export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="module-reaction-viewer__body__row__emoji">
|
<div className="module-reaction-viewer__body__row__emoji">
|
||||||
<Emoji size={18} emoji={emoji} />
|
<ReactionViewerEmoji emojiVariantValue={emoji} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@@ -16,6 +16,7 @@ import { WidthBreakpoint } from '../_util';
|
|||||||
import { ThemeType } from '../../types/Util';
|
import { ThemeType } from '../../types/Util';
|
||||||
import { PaymentEventKind } from '../../types/Payment';
|
import { PaymentEventKind } from '../../types/Payment';
|
||||||
import { ErrorBoundary } from './ErrorBoundary';
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
|
import { EmojiSkinTone } from '../fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
@@ -26,8 +27,10 @@ const renderEmojiPicker: TimelineItemProps['renderEmojiPicker'] = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
skinTone={0}
|
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||||
onSetSkinTone={action('EmojiPicker::onSetSkinTone')}
|
onEmojiSkinToneDefaultChange={action(
|
||||||
|
'EmojiPicker::onEmojiSkinToneDefaultChange'
|
||||||
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onPickEmoji={onPickEmoji}
|
onPickEmoji={onPickEmoji}
|
||||||
|
@@ -42,6 +42,7 @@ import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
|
|||||||
import { ThemeType } from '../../types/Util';
|
import { ThemeType } from '../../types/Util';
|
||||||
import { BadgeCategory } from '../../badges/BadgeCategory';
|
import { BadgeCategory } from '../../badges/BadgeCategory';
|
||||||
import { PaymentEventKind } from '../../types/Payment';
|
import { PaymentEventKind } from '../../types/Payment';
|
||||||
|
import { EmojiSkinTone } from '../fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
@@ -112,8 +113,10 @@ const renderEmojiPicker: Props['renderEmojiPicker'] = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
skinTone={0}
|
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||||
onSetSkinTone={action('EmojiPicker::onSetSkinTone')}
|
onEmojiSkinToneDefaultChange={action(
|
||||||
|
'EmojiPicker::onEmojiSkinToneDefaultChange'
|
||||||
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onPickEmoji={onPickEmoji}
|
onPickEmoji={onPickEmoji}
|
||||||
|
@@ -172,7 +172,7 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
|
|||||||
bodyRanges={displayBodyRanges}
|
bodyRanges={displayBodyRanges}
|
||||||
direction={undefined}
|
direction={undefined}
|
||||||
disableLinks
|
disableLinks
|
||||||
emojiSizeClass={undefined}
|
jumboEmojiSize={null}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
isSpoilerExpanded={EMPTY_OBJECT}
|
isSpoilerExpanded={EMPTY_OBJECT}
|
||||||
onMentionTrigger={noop}
|
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 { Meta } from '@storybook/react';
|
||||||
import type { Props } from './EmojiButton';
|
import type { Props } from './EmojiButton';
|
||||||
import { EmojiButton } from './EmojiButton';
|
import { EmojiButton } from './EmojiButton';
|
||||||
|
import { EmojiSkinTone } from '../fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
@@ -26,8 +27,8 @@ export function Base(): JSX.Element {
|
|||||||
<EmojiButton
|
<EmojiButton
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onPickEmoji={action('onPickEmoji')}
|
onPickEmoji={action('onPickEmoji')}
|
||||||
skinTone={0}
|
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||||
onSetSkinTone={action('onSetSkinTone')}
|
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
|
||||||
recentEmojis={[
|
recentEmojis={[
|
||||||
'grinning',
|
'grinning',
|
||||||
'grin',
|
'grin',
|
||||||
|
@@ -6,13 +6,20 @@ import type { MutableRefObject } from 'react';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { get, noop } from 'lodash';
|
import { get, noop } from 'lodash';
|
||||||
import { Manager, Popper, Reference } from 'react-popper';
|
import { Manager, Popper, Reference } from 'react-popper';
|
||||||
import { Emoji } from './Emoji';
|
|
||||||
import type { Props as EmojiPickerProps } from './EmojiPicker';
|
import type { Props as EmojiPickerProps } from './EmojiPicker';
|
||||||
import { EmojiPicker } from './EmojiPicker';
|
import { EmojiPicker } from './EmojiPicker';
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import { useRefMerger } from '../../hooks/useRefMerger';
|
import { useRefMerger } from '../../hooks/useRefMerger';
|
||||||
import { handleOutsideClick } from '../../util/handleOutsideClick';
|
import { handleOutsideClick } from '../../util/handleOutsideClick';
|
||||||
import * as KeyboardLayout from '../../services/keyboardLayout';
|
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 {
|
export enum EmojiButtonVariant {
|
||||||
Normal,
|
Normal,
|
||||||
@@ -33,7 +40,10 @@ export type OwnProps = Readonly<{
|
|||||||
export type Props = OwnProps &
|
export type Props = OwnProps &
|
||||||
Pick<
|
Pick<
|
||||||
EmojiPickerProps,
|
EmojiPickerProps,
|
||||||
'onPickEmoji' | 'onSetSkinTone' | 'recentEmojis' | 'skinTone'
|
| 'onPickEmoji'
|
||||||
|
| 'onEmojiSkinToneDefaultChange'
|
||||||
|
| 'recentEmojis'
|
||||||
|
| 'emojiSkinToneDefault'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type EmojiButtonAPI = Readonly<{
|
export type EmojiButtonAPI = Readonly<{
|
||||||
@@ -49,8 +59,8 @@ export const EmojiButton = React.memo(function EmojiButtonInner({
|
|||||||
onClose,
|
onClose,
|
||||||
onOpen,
|
onOpen,
|
||||||
onPickEmoji,
|
onPickEmoji,
|
||||||
skinTone,
|
emojiSkinToneDefault,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
variant = EmojiButtonVariant.Normal,
|
variant = EmojiButtonVariant.Normal,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -150,6 +160,13 @@ export const EmojiButton = React.memo(function EmojiButtonInner({
|
|||||||
};
|
};
|
||||||
}, [open, setOpen]);
|
}, [open, setOpen]);
|
||||||
|
|
||||||
|
let emojiVariant: EmojiVariantData;
|
||||||
|
if (emoji != null) {
|
||||||
|
strictAssert(isEmojiVariantValue(emoji), 'Must be emoji variant value');
|
||||||
|
const emojiVariantKey = getEmojiVariantKeyByValue(emoji);
|
||||||
|
emojiVariant = getEmojiVariantByKey(emojiVariantKey);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Manager>
|
<Manager>
|
||||||
<Reference>
|
<Reference>
|
||||||
@@ -167,7 +184,13 @@ export const EmojiButton = React.memo(function EmojiButtonInner({
|
|||||||
})}
|
})}
|
||||||
aria-label={i18n('icu:EmojiButton__label')}
|
aria-label={i18n('icu:EmojiButton__label')}
|
||||||
>
|
>
|
||||||
{emoji && <Emoji emoji={emoji} size={24} />}
|
{emojiVariant && (
|
||||||
|
<FunStaticEmoji
|
||||||
|
role="presentation"
|
||||||
|
emoji={emojiVariant}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Reference>
|
</Reference>
|
||||||
@@ -186,8 +209,8 @@ export const EmojiButton = React.memo(function EmojiButtonInner({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
skinTone={skinTone}
|
emojiSkinToneDefault={emojiSkinToneDefault}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
|
||||||
wasInvokedFromKeyboard={wasInvokedFromKeyboard}
|
wasInvokedFromKeyboard={wasInvokedFromKeyboard}
|
||||||
recentEmojis={recentEmojis}
|
recentEmojis={recentEmojis}
|
||||||
/>
|
/>
|
||||||
|
@@ -6,6 +6,7 @@ import { action } from '@storybook/addon-actions';
|
|||||||
import type { Meta } from '@storybook/react';
|
import type { Meta } from '@storybook/react';
|
||||||
import type { Props } from './EmojiPicker';
|
import type { Props } from './EmojiPicker';
|
||||||
import { EmojiPicker } from './EmojiPicker';
|
import { EmojiPicker } from './EmojiPicker';
|
||||||
|
import { EmojiSkinTone } from '../fun/data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
@@ -18,9 +19,9 @@ export function Base(): JSX.Element {
|
|||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onPickEmoji={action('onPickEmoji')}
|
onPickEmoji={action('onPickEmoji')}
|
||||||
onSetSkinTone={action('onSetSkinTone')}
|
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
|
||||||
onClose={action('onClose')}
|
onClose={action('onClose')}
|
||||||
skinTone={0}
|
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||||
recentEmojis={[
|
recentEmojis={[
|
||||||
'grinning',
|
'grinning',
|
||||||
'grin',
|
'grin',
|
||||||
@@ -65,9 +66,9 @@ export function NoRecents(): JSX.Element {
|
|||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onPickEmoji={action('onPickEmoji')}
|
onPickEmoji={action('onPickEmoji')}
|
||||||
onSetSkinTone={action('onSetSkinTone')}
|
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
|
||||||
onClose={action('onClose')}
|
onClose={action('onClose')}
|
||||||
skinTone={0}
|
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||||
recentEmojis={[]}
|
recentEmojis={[]}
|
||||||
wasInvokedFromKeyboard={false}
|
wasInvokedFromKeyboard={false}
|
||||||
/>
|
/>
|
||||||
@@ -79,10 +80,10 @@ export function WithSettingsButton(): JSX.Element {
|
|||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onPickEmoji={action('onPickEmoji')}
|
onPickEmoji={action('onPickEmoji')}
|
||||||
onSetSkinTone={action('onSetSkinTone')}
|
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
|
||||||
onClickSettings={action('onClickSettings')}
|
onClickSettings={action('onClickSettings')}
|
||||||
onClose={action('onClose')}
|
onClose={action('onClose')}
|
||||||
skinTone={0}
|
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||||
recentEmojis={[]}
|
recentEmojis={[]}
|
||||||
wasInvokedFromKeyboard={false}
|
wasInvokedFromKeyboard={false}
|
||||||
/>
|
/>
|
||||||
|
@@ -20,26 +20,39 @@ import {
|
|||||||
} from 'lodash';
|
} from 'lodash';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
|
||||||
import { Emoji } from './Emoji';
|
|
||||||
import { dataByCategory } from './lib';
|
import { dataByCategory } from './lib';
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import { isSingleGrapheme } from '../../util/grapheme';
|
import { isSingleGrapheme } from '../../util/grapheme';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import { useEmojiSearch } from '../../hooks/useEmojiSearch';
|
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 = {
|
export type EmojiPickDataType = {
|
||||||
skinTone?: number;
|
skinTone: EmojiSkinTone;
|
||||||
shortName: string;
|
shortName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OwnProps = {
|
export type OwnProps = {
|
||||||
readonly i18n: LocalizerType;
|
readonly i18n: LocalizerType;
|
||||||
readonly recentEmojis?: ReadonlyArray<string>;
|
readonly recentEmojis?: ReadonlyArray<string>;
|
||||||
readonly skinTone?: number;
|
readonly emojiSkinToneDefault: EmojiSkinTone;
|
||||||
readonly onClickSettings?: () => unknown;
|
readonly onClickSettings?: () => unknown;
|
||||||
readonly onClose?: () => unknown;
|
readonly onClose?: () => unknown;
|
||||||
readonly onPickEmoji: (o: EmojiPickDataType) => unknown;
|
readonly onPickEmoji: (o: EmojiPickDataType) => unknown;
|
||||||
readonly onSetSkinTone?: (tone: number) => unknown;
|
readonly onEmojiSkinToneDefaultChange?: (
|
||||||
|
emojiSkinTone: EmojiSkinTone
|
||||||
|
) => void;
|
||||||
readonly wasInvokedFromKeyboard: boolean;
|
readonly wasInvokedFromKeyboard: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,8 +97,8 @@ export const EmojiPicker = React.memo(
|
|||||||
{
|
{
|
||||||
i18n,
|
i18n,
|
||||||
onPickEmoji,
|
onPickEmoji,
|
||||||
skinTone = 0,
|
emojiSkinToneDefault = EmojiSkinTone.None,
|
||||||
onSetSkinTone,
|
onEmojiSkinToneDefaultChange,
|
||||||
recentEmojis = [],
|
recentEmojis = [],
|
||||||
style,
|
style,
|
||||||
onClickSettings,
|
onClickSettings,
|
||||||
@@ -107,7 +120,8 @@ export const EmojiPicker = React.memo(
|
|||||||
const [searchMode, setSearchMode] = React.useState(false);
|
const [searchMode, setSearchMode] = React.useState(false);
|
||||||
const [searchText, setSearchText] = React.useState('');
|
const [searchText, setSearchText] = React.useState('');
|
||||||
const [scrollToRow, setScrollToRow] = React.useState(0);
|
const [scrollToRow, setScrollToRow] = React.useState(0);
|
||||||
const [selectedTone, setSelectedTone] = React.useState(skinTone);
|
const [selectedTone, setSelectedTone] =
|
||||||
|
React.useState(emojiSkinToneDefault);
|
||||||
|
|
||||||
const search = useEmojiSearch(i18n.getLocale());
|
const search = useEmojiSearch(i18n.getLocale());
|
||||||
|
|
||||||
@@ -146,28 +160,6 @@ export const EmojiPicker = React.memo(
|
|||||||
[debounceSearchChange]
|
[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(
|
const handlePickEmoji = React.useCallback(
|
||||||
(
|
(
|
||||||
e:
|
e:
|
||||||
@@ -327,7 +319,21 @@ export const EmojiPicker = React.memo(
|
|||||||
({ key, style: cellStyle, rowIndex, columnIndex }) => {
|
({ key, style: cellStyle, rowIndex, columnIndex }) => {
|
||||||
const shortName = emojiGrid[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
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="module-emoji-picker__body__emoji-cell"
|
className="module-emoji-picker__body__emoji-cell"
|
||||||
@@ -341,10 +347,14 @@ export const EmojiPicker = React.memo(
|
|||||||
data-short-name={shortName}
|
data-short-name={shortName}
|
||||||
title={shortName}
|
title={shortName}
|
||||||
>
|
>
|
||||||
<Emoji shortName={shortName} skinTone={selectedTone} />
|
<FunStaticEmoji
|
||||||
|
role="presentation"
|
||||||
|
emoji={variantKey}
|
||||||
|
size={28}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
);
|
||||||
},
|
},
|
||||||
[emojiGrid, handlePickEmoji, selectedTone]
|
[emojiGrid, handlePickEmoji, selectedTone]
|
||||||
);
|
);
|
||||||
@@ -505,11 +515,19 @@ export const EmojiPicker = React.memo(
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{i18n('icu:EmojiPicker--empty')}
|
{i18n('icu:EmojiPicker--empty')}
|
||||||
<Emoji
|
<span
|
||||||
shortName="slightly_frowning_face"
|
style={{
|
||||||
size={16}
|
display: 'inline-block',
|
||||||
style={{ marginInlineStart: '4px' }}
|
marginInlineStart: '4px',
|
||||||
/>
|
}}
|
||||||
|
>
|
||||||
|
<FunStaticEmoji
|
||||||
|
role="presentation"
|
||||||
|
// Slightly Frowning Face
|
||||||
|
emoji={emojiVariantConstant('\u{1F641}')}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<footer className="module-emoji-picker__footer">
|
<footer className="module-emoji-picker__footer">
|
||||||
@@ -540,34 +558,51 @@ export const EmojiPicker = React.memo(
|
|||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onSetSkinTone ? (
|
{onEmojiSkinToneDefaultChange != null ? (
|
||||||
<div className="module-emoji-picker__footer__skin-tones">
|
<div className="module-emoji-picker__footer__skin-tones">
|
||||||
{[0, 1, 2, 3, 4, 5].map(tone => (
|
{EMOJI_SKIN_TONE_ORDER.map(emojiSkinTone => {
|
||||||
<button
|
return (
|
||||||
aria-pressed={selectedTone === tone}
|
<button
|
||||||
type="button"
|
aria-pressed={selectedTone === emojiSkinTone}
|
||||||
key={tone}
|
type="button"
|
||||||
data-tone={tone}
|
key={emojiSkinTone}
|
||||||
onClick={handlePickTone}
|
data-tone={emojiSkinTone}
|
||||||
onKeyDown={event => {
|
onClick={() => {
|
||||||
if (event.key === 'Enter' || event.key === 'Space') {
|
setIsUsingKeyboard(false);
|
||||||
handlePickTone(event);
|
setSelectedTone(emojiSkinTone);
|
||||||
}
|
onEmojiSkinToneDefaultChange(emojiSkinTone);
|
||||||
}}
|
}}
|
||||||
title={i18n('icu:EmojiPicker--skin-tone', {
|
onKeyDown={event => {
|
||||||
tone: `${tone}`,
|
if (event.key === 'Enter' || event.key === 'Space') {
|
||||||
})}
|
event.preventDefault();
|
||||||
className={classNames(
|
event.stopPropagation();
|
||||||
'module-emoji-picker__button',
|
setSelectedTone(emojiSkinTone);
|
||||||
'module-emoji-picker__button--footer',
|
onEmojiSkinToneDefaultChange(emojiSkinTone);
|
||||||
selectedTone === tone
|
}
|
||||||
? 'module-emoji-picker__button--selected'
|
}}
|
||||||
: null
|
title={i18n('icu:EmojiPicker--skin-tone', {
|
||||||
)}
|
tone: `${EMOJI_SKIN_TONE_TO_NUMBER.get(emojiSkinTone)}`,
|
||||||
>
|
})}
|
||||||
<Emoji shortName="hand" skinTone={tone} size={20} />
|
className={classNames(
|
||||||
</button>
|
'module-emoji-picker__button',
|
||||||
))}
|
'module-emoji-picker__button--footer',
|
||||||
|
selectedTone === emojiSkinTone
|
||||||
|
? 'module-emoji-picker__button--selected'
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FunStaticEmoji
|
||||||
|
role="presentation"
|
||||||
|
// Raised Hand
|
||||||
|
emoji={getEmojiVariantByParentKeyAndSkinTone(
|
||||||
|
emojiParentKeyConstant('\u{270B}'),
|
||||||
|
emojiSkinTone
|
||||||
|
)}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{Boolean(onClickSettings) && (
|
{Boolean(onClickSettings) && (
|
||||||
|
@@ -8,9 +8,7 @@ import Fuse from 'fuse.js';
|
|||||||
import {
|
import {
|
||||||
compact,
|
compact,
|
||||||
flatMap,
|
flatMap,
|
||||||
get,
|
|
||||||
groupBy,
|
groupBy,
|
||||||
isNumber,
|
|
||||||
keyBy,
|
keyBy,
|
||||||
map,
|
map,
|
||||||
mapValues,
|
mapValues,
|
||||||
@@ -19,6 +17,13 @@ import {
|
|||||||
} from 'lodash';
|
} from 'lodash';
|
||||||
import type { LocaleEmojiType } from '../../types/emoji';
|
import type { LocaleEmojiType } from '../../types/emoji';
|
||||||
import { getOwn } from '../../util/getOwn';
|
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.
|
// Import emoji-datasource dynamically to avoid costly typechecking.
|
||||||
// eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-var-requires
|
// 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 const skinTones = ['1F3FB', '1F3FC', '1F3FD', '1F3FE', '1F3FF'];
|
||||||
|
|
||||||
export type SkinToneKey = '1F3FB' | '1F3FC' | '1F3FD' | '1F3FE' | '1F3FF';
|
export type SkinToneKey = '1F3FB' | '1F3FC' | '1F3FD' | '1F3FE' | '1F3FF';
|
||||||
export type SizeClassType =
|
|
||||||
| ''
|
|
||||||
| 'small'
|
|
||||||
| 'medium'
|
|
||||||
| 'large'
|
|
||||||
| 'extra-large'
|
|
||||||
| 'max';
|
|
||||||
|
|
||||||
type EmojiSkinVariation = {
|
type EmojiSkinVariation = {
|
||||||
unified: string;
|
unified: string;
|
||||||
@@ -91,18 +89,7 @@ export const data = (untypedData as Array<EmojiData>)
|
|||||||
: emoji
|
: 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 dataByShortName = keyBy(data, 'short_name');
|
||||||
const imageByEmoji: { [key: string]: string } = {};
|
|
||||||
const dataByEmoji: { [key: string]: EmojiData } = {};
|
const dataByEmoji: { [key: string]: EmojiData } = {};
|
||||||
|
|
||||||
export const dataByCategory = mapValues(
|
export const dataByCategory = mapValues(
|
||||||
@@ -150,13 +137,12 @@ export const dataByCategory = mapValues(
|
|||||||
|
|
||||||
export function getEmojiData(
|
export function getEmojiData(
|
||||||
shortName: keyof typeof dataByShortName,
|
shortName: keyof typeof dataByShortName,
|
||||||
skinTone?: SkinToneKey | number
|
emojiSkinToneDefault: EmojiSkinTone
|
||||||
): EmojiData | EmojiSkinVariation {
|
): EmojiData | EmojiSkinVariation {
|
||||||
const base = dataByShortName[shortName];
|
const base = dataByShortName[shortName];
|
||||||
|
const variation = EMOJI_SKIN_TONE_TO_KEY.get(emojiSkinToneDefault);
|
||||||
|
|
||||||
if (skinTone && base.skin_variations) {
|
if (variation != null && base.skin_variations) {
|
||||||
const variation = isNumber(skinTone) ? skinTones[skinTone - 1] : skinTone;
|
|
||||||
|
|
||||||
if (base.skin_variations[variation]) {
|
if (base.skin_variations[variation]) {
|
||||||
return base.skin_variations[variation];
|
return base.skin_variations[variation];
|
||||||
}
|
}
|
||||||
@@ -171,15 +157,6 @@ export function getEmojiData(
|
|||||||
return base;
|
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 SearchFnType = (query: string, count?: number) => Array<string>;
|
||||||
|
|
||||||
export type SearchEmojiListType = ReadonlyArray<
|
export type SearchEmojiListType = ReadonlyArray<
|
||||||
@@ -295,7 +272,7 @@ export function unifiedToEmoji(unified: string): string {
|
|||||||
|
|
||||||
export function convertShortNameToData(
|
export function convertShortNameToData(
|
||||||
shortName: string,
|
shortName: string,
|
||||||
skinTone: number | SkinToneKey = 0
|
skinTone: EmojiSkinTone
|
||||||
): EmojiData | undefined {
|
): EmojiData | undefined {
|
||||||
const base = dataByShortName[shortName];
|
const base = dataByShortName[shortName];
|
||||||
|
|
||||||
@@ -303,10 +280,12 @@ export function convertShortNameToData(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toneKey = isNumber(skinTone) ? skinTones[skinTone - 1] : skinTone;
|
if (skinTone !== EmojiSkinTone.None && base.skin_variations != null) {
|
||||||
|
const toneKey = EMOJI_SKIN_TONE_TO_KEY.get(skinTone);
|
||||||
if (skinTone && base.skin_variations) {
|
strictAssert(toneKey, `Missing key for skin tone: ${skinTone}`);
|
||||||
const variation = base.skin_variations[toneKey];
|
const variation =
|
||||||
|
base.skin_variations[toneKey] ??
|
||||||
|
base.skin_variations[`${toneKey}-${toneKey}`];
|
||||||
if (variation) {
|
if (variation) {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -320,7 +299,7 @@ export function convertShortNameToData(
|
|||||||
|
|
||||||
export function convertShortName(
|
export function convertShortName(
|
||||||
shortName: string,
|
shortName: string,
|
||||||
skinTone: number | SkinToneKey = 0
|
skinTone: EmojiSkinTone
|
||||||
): string {
|
): string {
|
||||||
const emojiData = convertShortNameToData(shortName, skinTone);
|
const emojiData = convertShortNameToData(shortName, skinTone);
|
||||||
|
|
||||||
@@ -331,10 +310,6 @@ export function convertShortName(
|
|||||||
return unifiedToEmoji(emojiData.unified);
|
return unifiedToEmoji(emojiData.unified);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emojiToImage(emoji: string): string | undefined {
|
|
||||||
return getOwn(imageByEmoji, emoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emojiToData(emoji: string): EmojiData | undefined {
|
export function emojiToData(emoji: string): EmojiData | undefined {
|
||||||
return getOwn(dataByEmoji, emoji);
|
return getOwn(dataByEmoji, emoji);
|
||||||
}
|
}
|
||||||
@@ -361,34 +336,34 @@ export function hasNonEmojiText(str: string): boolean {
|
|||||||
return str.replace(emojiRegex(), '').trim().length > 0;
|
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?
|
// Do we have non-emoji characters?
|
||||||
if (hasNonEmojiText(str)) {
|
if (hasNonEmojiText(str)) {
|
||||||
return '';
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emojiCount = getEmojiCount(str);
|
const emojiCount = getEmojiCount(str);
|
||||||
|
|
||||||
if (emojiCount === 1) {
|
if (emojiCount === 1) {
|
||||||
return 'max';
|
return FunJumboEmojiSize.Max;
|
||||||
}
|
}
|
||||||
if (emojiCount === 2) {
|
if (emojiCount === 2) {
|
||||||
return 'extra-large';
|
return FunJumboEmojiSize.ExtraLarge;
|
||||||
}
|
}
|
||||||
if (emojiCount === 3) {
|
if (emojiCount === 3) {
|
||||||
return 'large';
|
return FunJumboEmojiSize.Large;
|
||||||
}
|
}
|
||||||
if (emojiCount === 4) {
|
if (emojiCount === 4) {
|
||||||
return 'medium';
|
return FunJumboEmojiSize.Medium;
|
||||||
}
|
}
|
||||||
if (emojiCount === 5) {
|
if (emojiCount === 5) {
|
||||||
return 'small';
|
return FunJumboEmojiSize.Small;
|
||||||
}
|
}
|
||||||
return '';
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
data.forEach(emoji => {
|
data.forEach(emoji => {
|
||||||
const { short_name, short_names, skin_variations, image } = emoji;
|
const { short_name, short_names, skin_variations } = emoji;
|
||||||
|
|
||||||
if (short_names) {
|
if (short_names) {
|
||||||
short_names.forEach(name => {
|
short_names.forEach(name => {
|
||||||
@@ -396,14 +371,14 @@ data.forEach(emoji => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
imageByEmoji[convertShortName(short_name)] = makeImagePath(image);
|
dataByEmoji[convertShortName(short_name, EmojiSkinTone.None)] = emoji;
|
||||||
dataByEmoji[convertShortName(short_name)] = emoji;
|
|
||||||
|
|
||||||
if (skin_variations) {
|
if (skin_variations) {
|
||||||
Object.entries(skin_variations).forEach(([tone, variation]) => {
|
Object.entries(skin_variations).forEach(([tone]) => {
|
||||||
imageByEmoji[convertShortName(short_name, tone as SkinToneKey)] =
|
const emojiSkinTone = KEY_TO_EMOJI_SKIN_TONE.get(tone);
|
||||||
makeImagePath(variation.image);
|
if (emojiSkinTone != null) {
|
||||||
dataByEmoji[convertShortName(short_name, tone as SkinToneKey)] = emoji;
|
dataByEmoji[convertShortName(short_name, emojiSkinTone)] = emoji;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -4,8 +4,8 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
|||||||
import { chunk } from 'lodash';
|
import { chunk } from 'lodash';
|
||||||
import React, { StrictMode, useCallback, useEffect, useRef } from 'react';
|
import React, { StrictMode, useCallback, useEffect, useRef } from 'react';
|
||||||
import { type ComponentMeta } from '../../storybook/types';
|
import { type ComponentMeta } from '../../storybook/types';
|
||||||
import type { FunEmojiProps } from './FunEmoji';
|
import type { FunStaticEmojiProps } from './FunEmoji';
|
||||||
import { FunEmoji } from './FunEmoji';
|
import { FunStaticEmoji } from './FunEmoji';
|
||||||
import {
|
import {
|
||||||
_allEmojiVariantKeys,
|
_allEmojiVariantKeys,
|
||||||
getEmojiParentByKey,
|
getEmojiParentByKey,
|
||||||
@@ -26,7 +26,7 @@ export default {
|
|||||||
|
|
||||||
const COLUMNS = 8;
|
const COLUMNS = 8;
|
||||||
|
|
||||||
type AllProps = Pick<FunEmojiProps, 'size'>;
|
type AllProps = Pick<FunStaticEmojiProps, 'size'>;
|
||||||
|
|
||||||
export function All(props: AllProps): JSX.Element {
|
export function All(props: AllProps): JSX.Element {
|
||||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -102,7 +102,7 @@ export function All(props: AllProps): JSX.Element {
|
|||||||
key={emojiVariantKey}
|
key={emojiVariantKey}
|
||||||
style={{ display: 'flex', outline: '1px solid' }}
|
style={{ display: 'flex', outline: '1px solid' }}
|
||||||
>
|
>
|
||||||
<FunEmoji
|
<FunStaticEmoji
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={parent.englishShortNameDefault}
|
aria-label={parent.englishShortNameDefault}
|
||||||
size={props.size}
|
size={props.size}
|
||||||
|
@@ -2,35 +2,170 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import type { EmojiVariantData } from './data/emojis';
|
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<{
|
function getEmojiJumboUrl(emoji: EmojiVariantData): string {
|
||||||
role: 'img' | 'presentation';
|
return `emoji://jumbo?emoji=${encodeURIComponent(emoji.value)}`;
|
||||||
'aria-label': string;
|
}
|
||||||
size: FunEmojiSize;
|
|
||||||
emoji: EmojiVariantData;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const sizeToClassName: Record<FunEmojiSize, string> = {
|
export type FunStaticEmojiSize =
|
||||||
16: 'FunEmoji--Size16',
|
| 16
|
||||||
32: 'FunEmoji--Size32',
|
| 18
|
||||||
};
|
| 20
|
||||||
|
| 24
|
||||||
|
| 28
|
||||||
|
| 32
|
||||||
|
| 36
|
||||||
|
| 40
|
||||||
|
| 48
|
||||||
|
| 56
|
||||||
|
| 64
|
||||||
|
| 66;
|
||||||
|
|
||||||
export function FunEmoji(props: FunEmojiProps): JSX.Element {
|
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;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function FunStaticEmoji(props: FunStaticEmojiProps): JSX.Element {
|
||||||
|
const emojiJumboUrl = useMemo(() => {
|
||||||
|
return getEmojiJumboUrl(props.emoji);
|
||||||
|
}, [props.emoji]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role={props.role}
|
role={props.role}
|
||||||
aria-label={props['aria-label']}
|
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={
|
style={
|
||||||
{
|
{
|
||||||
'--fun-emoji-sheet-x': props.emoji.sheetX,
|
'--fun-emoji-sheet-x': props.emoji.sheetX,
|
||||||
'--fun-emoji-sheet-y': props.emoji.sheetY,
|
'--fun-emoji-sheet-y': props.emoji.sheetY,
|
||||||
|
'--fun-emoji-jumbo-image': `url(${emojiJumboUrl})`,
|
||||||
} as CSSProperties
|
} 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}
|
recentStickers={recentStickers}
|
||||||
recentGifs={[]}
|
recentGifs={[]}
|
||||||
// Emojis
|
// Emojis
|
||||||
defaultEmojiSkinTone={EmojiSkinTone.None}
|
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||||
onChangeDefaultEmojiSkinTone={() => null}
|
onEmojiSkinToneDefaultChange={() => null}
|
||||||
// Stickers
|
// Stickers
|
||||||
installedStickerPacks={packs}
|
installedStickerPacks={packs}
|
||||||
showStickerPickerHint={false}
|
showStickerPickerHint={false}
|
||||||
onClearStickerPickerHint={() => null}
|
onClearStickerPickerHint={() => null}
|
||||||
|
// Gifs
|
||||||
|
fetchGifsSearch={() => Promise.reject()}
|
||||||
|
fetchGifsFeatured={() => Promise.reject()}
|
||||||
|
fetchGif={() => Promise.reject()}
|
||||||
>
|
>
|
||||||
<FunEmojiPicker {...props}>
|
<FunEmojiPicker {...props}>
|
||||||
<Button>Open EmojiPicker</Button>
|
<Button>Open EmojiPicker</Button>
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import type { ReactNode } from 'react';
|
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 type { Placement } from 'react-aria';
|
||||||
import { DialogTrigger } from 'react-aria-components';
|
import { DialogTrigger } from 'react-aria-components';
|
||||||
import { FunPopover } from './base/FunPopover';
|
import { FunPopover } from './base/FunPopover';
|
||||||
import type { FunEmojiSelection } from './panels/FunPanelEmojis';
|
import type { FunEmojiSelection } from './panels/FunPanelEmojis';
|
||||||
import { FunPanelEmojis } from './panels/FunPanelEmojis';
|
import { FunPanelEmojis } from './panels/FunPanelEmojis';
|
||||||
|
import { useFunContext } from './FunProvider';
|
||||||
|
|
||||||
export type FunEmojiPickerProps = Readonly<{
|
export type FunEmojiPickerProps = Readonly<{
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
@@ -20,6 +21,8 @@ export const FunEmojiPicker = memo(function FunEmojiPicker(
|
|||||||
props: FunEmojiPickerProps
|
props: FunEmojiPickerProps
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
const { onOpenChange } = props;
|
const { onOpenChange } = props;
|
||||||
|
const fun = useFunContext();
|
||||||
|
const { onClose } = fun;
|
||||||
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
|
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
@@ -34,6 +37,12 @@ export const FunEmojiPicker = memo(function FunEmojiPicker(
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
|
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
|
||||||
{props.children}
|
{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 { packs, recentStickers } from '../stickers/mocks';
|
||||||
import type { FunPickerProps } from './FunPicker';
|
import type { FunPickerProps } from './FunPicker';
|
||||||
import { FunPicker } from './FunPicker';
|
import { FunPicker } from './FunPicker';
|
||||||
|
import type { FunProviderProps } from './FunProvider';
|
||||||
import { FunProvider } 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';
|
import { EmojiSkinTone } from './data/emojis';
|
||||||
|
|
||||||
const { i18n } = window.SignalContext;
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
type TemplateProps = Omit<FunPickerProps, 'children'>;
|
type TemplateProps = Omit<FunPickerProps, 'children'> &
|
||||||
|
Pick<FunProviderProps, 'fetchGifsSearch' | 'fetchGifsFeatured' | 'fetchGif'>;
|
||||||
|
|
||||||
function Template(props: TemplateProps) {
|
function Template(props: TemplateProps) {
|
||||||
return (
|
return (
|
||||||
@@ -25,12 +27,16 @@ function Template(props: TemplateProps) {
|
|||||||
recentStickers={recentStickers}
|
recentStickers={recentStickers}
|
||||||
recentGifs={[]}
|
recentGifs={[]}
|
||||||
// Emojis
|
// Emojis
|
||||||
defaultEmojiSkinTone={EmojiSkinTone.None}
|
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||||
onChangeDefaultEmojiSkinTone={() => null}
|
onEmojiSkinToneDefaultChange={() => null}
|
||||||
// Stickers
|
// Stickers
|
||||||
installedStickerPacks={packs}
|
installedStickerPacks={packs}
|
||||||
showStickerPickerHint={false}
|
showStickerPickerHint={false}
|
||||||
onClearStickerPickerHint={() => null}
|
onClearStickerPickerHint={() => null}
|
||||||
|
// Gifs
|
||||||
|
fetchGifsSearch={props.fetchGifsSearch}
|
||||||
|
fetchGifsFeatured={props.fetchGifsFeatured}
|
||||||
|
fetchGif={props.fetchGif}
|
||||||
>
|
>
|
||||||
<FunPicker {...props}>
|
<FunPicker {...props}>
|
||||||
<Button>Open FunPicker</Button>
|
<Button>Open FunPicker</Button>
|
||||||
@@ -47,10 +53,13 @@ export default {
|
|||||||
placement: 'bottom',
|
placement: 'bottom',
|
||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
onOpenChange: action('onOpenChange'),
|
onOpenChange: action('onOpenChange'),
|
||||||
onSelectEmoji: action('onPickEmoji'),
|
onSelectEmoji: action('onSelectEmoji'),
|
||||||
onSelectSticker: action('onPickSticker'),
|
onSelectSticker: action('onSelectSticker'),
|
||||||
onSelectGif: action('onPickGif'),
|
onSelectGif: action('onSelectGif'),
|
||||||
onAddStickerPack: action('onAddStickerPack'),
|
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>;
|
} satisfies ComponentMeta<TemplateProps>;
|
||||||
|
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import type { ReactNode } from 'react';
|
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 type { Placement } from 'react-aria';
|
||||||
import { DialogTrigger } from 'react-aria-components';
|
import { DialogTrigger } from 'react-aria-components';
|
||||||
import { FunPickerTabKey } from './FunConstants';
|
import { FunPickerTabKey } from './constants';
|
||||||
import { FunPopover } from './base/FunPopover';
|
import { FunPopover } from './base/FunPopover';
|
||||||
import { FunPickerTab, FunTabList, FunTabPanel, FunTabs } from './base/FunTabs';
|
import { FunPickerTab, FunTabList, FunTabPanel, FunTabs } from './base/FunTabs';
|
||||||
import type { FunEmojiSelection } from './panels/FunPanelEmojis';
|
import type { FunEmojiSelection } from './panels/FunPanelEmojis';
|
||||||
@@ -35,7 +35,7 @@ export const FunPicker = memo(function FunPicker(
|
|||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
const { onOpenChange } = props;
|
const { onOpenChange } = props;
|
||||||
const fun = useFunContext();
|
const fun = useFunContext();
|
||||||
const { i18n } = fun;
|
const { i18n, onClose } = fun;
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
|
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
|
||||||
|
|
||||||
@@ -51,6 +51,12 @@ export const FunPicker = memo(function FunPicker(
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
|
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
@@ -14,7 +14,7 @@ import type { StickerPackType, StickerType } from '../../state/ducks/stickers';
|
|||||||
import type { EmojiSkinTone } from './data/emojis';
|
import type { EmojiSkinTone } from './data/emojis';
|
||||||
import { EmojiPickerCategory, type EmojiParentKey } from './data/emojis';
|
import { EmojiPickerCategory, type EmojiParentKey } from './data/emojis';
|
||||||
import type { GifType } from './panels/FunPanelGifs';
|
import type { GifType } from './panels/FunPanelGifs';
|
||||||
import type { FunGifsSection, FunStickersSection } from './FunConstants';
|
import type { FunGifsSection, FunStickersSection } from './constants';
|
||||||
import {
|
import {
|
||||||
type FunEmojisSection,
|
type FunEmojisSection,
|
||||||
FunGifsCategory,
|
FunGifsCategory,
|
||||||
@@ -22,7 +22,9 @@ import {
|
|||||||
FunSectionCommon,
|
FunSectionCommon,
|
||||||
FunStickersSectionBase,
|
FunStickersSectionBase,
|
||||||
toFunStickersPackSection,
|
toFunStickersPackSection,
|
||||||
} from './FunConstants';
|
} from './constants';
|
||||||
|
import type { fetchGifsFeatured, fetchGifsSearch } from './data/gifs';
|
||||||
|
import type { tenorDownload } from './data/tenor';
|
||||||
|
|
||||||
export type FunContextSmartProps = Readonly<{
|
export type FunContextSmartProps = Readonly<{
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
@@ -33,17 +35,25 @@ export type FunContextSmartProps = Readonly<{
|
|||||||
recentGifs: ReadonlyArray<GifType>;
|
recentGifs: ReadonlyArray<GifType>;
|
||||||
|
|
||||||
// Emojis
|
// Emojis
|
||||||
defaultEmojiSkinTone: EmojiSkinTone;
|
emojiSkinToneDefault: EmojiSkinTone;
|
||||||
onChangeDefaultEmojiSkinTone: (emojiSkinTone: EmojiSkinTone) => void;
|
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
|
||||||
|
|
||||||
// Stickers
|
// Stickers
|
||||||
installedStickerPacks: ReadonlyArray<StickerPackType>;
|
installedStickerPacks: ReadonlyArray<StickerPackType>;
|
||||||
showStickerPickerHint: boolean;
|
showStickerPickerHint: boolean;
|
||||||
onClearStickerPickerHint: () => unknown;
|
onClearStickerPickerHint: () => unknown;
|
||||||
|
|
||||||
|
// GIFs
|
||||||
|
fetchGifsFeatured: typeof fetchGifsFeatured;
|
||||||
|
fetchGifsSearch: typeof fetchGifsSearch;
|
||||||
|
fetchGif: typeof tenorDownload;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type FunContextProps = FunContextSmartProps &
|
export type FunContextProps = FunContextSmartProps &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
|
// Open state
|
||||||
|
onClose: () => void;
|
||||||
|
|
||||||
// Current Tab
|
// Current Tab
|
||||||
tab: FunPickerTabKey;
|
tab: FunPickerTabKey;
|
||||||
onChangeTab: (key: FunPickerTabKey) => unknown;
|
onChangeTab: (key: FunPickerTabKey) => unknown;
|
||||||
@@ -101,16 +111,38 @@ export const FunProvider = memo(function FunProvider(
|
|||||||
setSearchInput(newSearchInput);
|
setSearchInput(newSearchInput);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const defaultEmojiSection = useMemo((): FunEmojisSection => {
|
||||||
|
if (props.recentEmojis.length) {
|
||||||
|
return FunSectionCommon.Recents;
|
||||||
|
}
|
||||||
|
return EmojiPickerCategory.SmileysAndPeople;
|
||||||
|
}, [props.recentEmojis]);
|
||||||
|
|
||||||
|
const defaultStickerSection = useMemo((): FunStickersSection => {
|
||||||
|
if (props.recentStickers.length > 0) {
|
||||||
|
return FunSectionCommon.Recents;
|
||||||
|
}
|
||||||
|
const firstInstalledStickerPack = props.installedStickerPacks.at(0);
|
||||||
|
if (firstInstalledStickerPack != null) {
|
||||||
|
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
|
// Selected Sections
|
||||||
const [selectedEmojisSection, setSelectedEmojisSection] = useState(
|
const [selectedEmojisSection, setSelectedEmojisSection] = useState(
|
||||||
(): FunEmojisSection => {
|
(): FunEmojisSection => {
|
||||||
if (searchQuery !== '') {
|
if (searchQuery !== '') {
|
||||||
return FunSectionCommon.SearchResults;
|
return FunSectionCommon.SearchResults;
|
||||||
}
|
}
|
||||||
if (props.recentEmojis.length) {
|
return defaultEmojiSection;
|
||||||
return FunSectionCommon.Recents;
|
|
||||||
}
|
|
||||||
return EmojiPickerCategory.SmileysAndPeople;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const [selectedStickersSection, setSelectedStickersSection] = useState(
|
const [selectedStickersSection, setSelectedStickersSection] = useState(
|
||||||
@@ -118,14 +150,7 @@ export const FunProvider = memo(function FunProvider(
|
|||||||
if (searchQuery !== '') {
|
if (searchQuery !== '') {
|
||||||
return FunSectionCommon.SearchResults;
|
return FunSectionCommon.SearchResults;
|
||||||
}
|
}
|
||||||
if (props.recentStickers.length > 0) {
|
return defaultStickerSection;
|
||||||
return FunSectionCommon.Recents;
|
|
||||||
}
|
|
||||||
const firstInstalledStickerPack = props.installedStickerPacks.at(0);
|
|
||||||
if (firstInstalledStickerPack != null) {
|
|
||||||
return toFunStickersPackSection(firstInstalledStickerPack);
|
|
||||||
}
|
|
||||||
return FunStickersSectionBase.StickersSetup;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const [selectedGifsSection, setSelectedGifsSection] = useState(
|
const [selectedGifsSection, setSelectedGifsSection] = useState(
|
||||||
@@ -133,10 +158,7 @@ export const FunProvider = memo(function FunProvider(
|
|||||||
if (searchQuery !== '') {
|
if (searchQuery !== '') {
|
||||||
return FunSectionCommon.SearchResults;
|
return FunSectionCommon.SearchResults;
|
||||||
}
|
}
|
||||||
if (props.recentGifs.length > 0) {
|
return defaultGifsSection;
|
||||||
return FunSectionCommon.Recents;
|
|
||||||
}
|
|
||||||
return FunGifsCategory.Trending;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const handleChangeSelectedEmojisSection = useCallback(
|
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 (
|
return (
|
||||||
<FunProviderInner
|
<FunProviderInner
|
||||||
i18n={props.i18n}
|
i18n={props.i18n}
|
||||||
|
// Open state
|
||||||
|
onClose={handleClose}
|
||||||
// Current Tab
|
// Current Tab
|
||||||
tab={tab}
|
tab={tab}
|
||||||
onChangeTab={handleChangeTab}
|
onChangeTab={handleChangeTab}
|
||||||
@@ -179,12 +210,16 @@ export const FunProvider = memo(function FunProvider(
|
|||||||
recentStickers={props.recentStickers}
|
recentStickers={props.recentStickers}
|
||||||
recentGifs={props.recentGifs}
|
recentGifs={props.recentGifs}
|
||||||
// Emojis
|
// Emojis
|
||||||
defaultEmojiSkinTone={props.defaultEmojiSkinTone}
|
emojiSkinToneDefault={props.emojiSkinToneDefault}
|
||||||
onChangeDefaultEmojiSkinTone={props.onChangeDefaultEmojiSkinTone}
|
onEmojiSkinToneDefaultChange={props.onEmojiSkinToneDefaultChange}
|
||||||
// Stickers
|
// Stickers
|
||||||
installedStickerPacks={props.installedStickerPacks}
|
installedStickerPacks={props.installedStickerPacks}
|
||||||
showStickerPickerHint={props.showStickerPickerHint}
|
showStickerPickerHint={props.showStickerPickerHint}
|
||||||
onClearStickerPickerHint={props.onClearStickerPickerHint}
|
onClearStickerPickerHint={props.onClearStickerPickerHint}
|
||||||
|
// GIFs
|
||||||
|
fetchGifsFeatured={props.fetchGifsFeatured}
|
||||||
|
fetchGifsSearch={props.fetchGifsSearch}
|
||||||
|
fetchGif={props.fetchGif}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</FunProviderInner>
|
</FunProviderInner>
|
||||||
|
@@ -3,22 +3,24 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import type { Selection } from 'react-aria-components';
|
import type { Selection } from 'react-aria-components';
|
||||||
import { ListBox, ListBoxItem } from 'react-aria-components';
|
import { ListBox, ListBoxItem } from 'react-aria-components';
|
||||||
import type { EmojiParentKey } from '../data/emojis';
|
import type { EmojiParentKey } from './data/emojis';
|
||||||
import {
|
import {
|
||||||
EmojiSkinTone,
|
EmojiSkinTone,
|
||||||
getEmojiVariantByParentKeyAndSkinTone,
|
getEmojiVariantByParentKeyAndSkinTone,
|
||||||
} from '../data/emojis';
|
} from './data/emojis';
|
||||||
import { strictAssert } from '../../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import { FunEmoji } from '../FunEmoji';
|
import { FunStaticEmoji } from './FunEmoji';
|
||||||
|
import type { LocalizerType } from '../../types/I18N';
|
||||||
|
|
||||||
export type SkinTonesListBoxProps = Readonly<{
|
export type FunSkinTonesListProps = Readonly<{
|
||||||
|
i18n: LocalizerType;
|
||||||
emoji: EmojiParentKey;
|
emoji: EmojiParentKey;
|
||||||
skinTone: EmojiSkinTone;
|
skinTone: EmojiSkinTone;
|
||||||
onSelectSkinTone: (skinTone: EmojiSkinTone) => void;
|
onSelectSkinTone: (skinTone: EmojiSkinTone) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function SkinTonesListBox(props: SkinTonesListBoxProps): JSX.Element {
|
export function FunSkinTonesList(props: FunSkinTonesListProps): JSX.Element {
|
||||||
const { onSelectSkinTone } = props;
|
const { i18n, onSelectSkinTone } = props;
|
||||||
|
|
||||||
const handleSelectionChange = useCallback(
|
const handleSelectionChange = useCallback(
|
||||||
(keys: Selection) => {
|
(keys: Selection) => {
|
||||||
@@ -32,50 +34,68 @@ export function SkinTonesListBox(props: SkinTonesListBoxProps): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ListBox
|
<ListBox
|
||||||
|
aria-label={i18n('icu:FunSkinTones__List')}
|
||||||
className="FunSkinTones__ListBox"
|
className="FunSkinTones__ListBox"
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
selectedKeys={[props.skinTone]}
|
selectedKeys={[props.skinTone]}
|
||||||
selectionMode="single"
|
selectionMode="single"
|
||||||
|
disallowEmptySelection
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
>
|
>
|
||||||
<SkinTonesListBoxItem emoji={props.emoji} skinTone={EmojiSkinTone.None} />
|
<FunSkinTonesListItem
|
||||||
<SkinTonesListBoxItem
|
emoji={props.emoji}
|
||||||
|
aria-label={i18n('icu:FunSkinTones__ListItem--Light')}
|
||||||
|
skinTone={EmojiSkinTone.None}
|
||||||
|
/>
|
||||||
|
<FunSkinTonesListItem
|
||||||
emoji={props.emoji}
|
emoji={props.emoji}
|
||||||
skinTone={EmojiSkinTone.Type1}
|
skinTone={EmojiSkinTone.Type1}
|
||||||
|
aria-label={i18n('icu:FunSkinTones__ListItem--Light')}
|
||||||
/>
|
/>
|
||||||
<SkinTonesListBoxItem
|
<FunSkinTonesListItem
|
||||||
emoji={props.emoji}
|
emoji={props.emoji}
|
||||||
skinTone={EmojiSkinTone.Type2}
|
skinTone={EmojiSkinTone.Type2}
|
||||||
|
aria-label={i18n('icu:FunSkinTones__ListItem--MediumLight')}
|
||||||
/>
|
/>
|
||||||
<SkinTonesListBoxItem
|
<FunSkinTonesListItem
|
||||||
emoji={props.emoji}
|
emoji={props.emoji}
|
||||||
skinTone={EmojiSkinTone.Type3}
|
skinTone={EmojiSkinTone.Type3}
|
||||||
|
aria-label={i18n('icu:FunSkinTones__ListItem--Medium')}
|
||||||
/>
|
/>
|
||||||
<SkinTonesListBoxItem
|
<FunSkinTonesListItem
|
||||||
emoji={props.emoji}
|
emoji={props.emoji}
|
||||||
skinTone={EmojiSkinTone.Type4}
|
skinTone={EmojiSkinTone.Type4}
|
||||||
|
aria-label={i18n('icu:FunSkinTones__ListItem--MediumDark')}
|
||||||
/>
|
/>
|
||||||
<SkinTonesListBoxItem
|
<FunSkinTonesListItem
|
||||||
emoji={props.emoji}
|
emoji={props.emoji}
|
||||||
skinTone={EmojiSkinTone.Type5}
|
skinTone={EmojiSkinTone.Type5}
|
||||||
|
aria-label={i18n('icu:FunSkinTones__ListItem--Dark')}
|
||||||
/>
|
/>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkinTonesListBoxItemProps = Readonly<{
|
type FunSkinTonesListItemProps = Readonly<{
|
||||||
emoji: EmojiParentKey;
|
emoji: EmojiParentKey;
|
||||||
|
'aria-label': string;
|
||||||
skinTone: EmojiSkinTone;
|
skinTone: EmojiSkinTone;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function SkinTonesListBoxItem(props: SkinTonesListBoxItemProps) {
|
function FunSkinTonesListItem(props: FunSkinTonesListItemProps) {
|
||||||
const variant = useMemo(() => {
|
const variant = useMemo(() => {
|
||||||
return getEmojiVariantByParentKeyAndSkinTone(props.emoji, props.skinTone);
|
return getEmojiVariantByParentKeyAndSkinTone(props.emoji, props.skinTone);
|
||||||
}, [props.emoji, props.skinTone]);
|
}, [props.emoji, props.skinTone]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListBoxItem id={props.skinTone} className="FunSkinTones__ListBoxItem">
|
<ListBoxItem
|
||||||
<FunEmoji role="presentation" aria-label="" size={32} emoji={variant} />
|
id={props.skinTone}
|
||||||
|
className="FunSkinTones__ListBoxItem"
|
||||||
|
aria-label={props['aria-label']}
|
||||||
|
>
|
||||||
|
<div className="FunSkinTones__ListBoxItemButton">
|
||||||
|
<FunStaticEmoji role="presentation" size={32} emoji={variant} />
|
||||||
|
</div>
|
||||||
</ListBoxItem>
|
</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
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import type { RefObject } from 'react';
|
import type { ForwardedRef, RefObject } from 'react';
|
||||||
import React, { useRef, useEffect, useState } from 'react';
|
import React, { useRef, useEffect, useState, forwardRef } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { isFocusable } from '@react-aria/focus';
|
import { isFocusable } from '@react-aria/focus';
|
||||||
import { strictAssert } from '../../../util/assert';
|
import { strictAssert } from '../../../util/assert';
|
||||||
import { useReducedMotion } from '../../../hooks/useReducedMotion';
|
import { useReducedMotion } from '../../../hooks/useReducedMotion';
|
||||||
|
import type { FunImageAriaProps } from '../types';
|
||||||
|
|
||||||
export type FunAnimatedImageProps = Readonly<{
|
export type FunImageProps = FunImageAriaProps &
|
||||||
role: 'image' | 'presentation';
|
Readonly<{
|
||||||
className?: string;
|
className?: string;
|
||||||
src: string;
|
src: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: 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 imageRef = useRef<HTMLImageElement>(null);
|
||||||
const intent = useIntent(imageRef);
|
const intent = useIntent(imageRef);
|
||||||
const [staticSource, setStaticSource] = useState<string | null>(null);
|
const [staticSource, setStaticSource] = useState<string | null>(null);
|
||||||
@@ -69,16 +97,7 @@ export function FunImage(props: FunAnimatedImageProps): JSX.Element {
|
|||||||
{staticSource != null && reducedMotion && !intent && (
|
{staticSource != null && reducedMotion && !intent && (
|
||||||
<source className="FunImage--StaticSource" srcSet={staticSource} />
|
<source className="FunImage--StaticSource" srcSet={staticSource} />
|
||||||
)}
|
)}
|
||||||
{/* Using <img> to benefit from browser */}
|
<FunImageBase {...props} ref={imageRef} />
|
||||||
<img
|
|
||||||
ref={imageRef}
|
|
||||||
role={props.role}
|
|
||||||
className={props.className}
|
|
||||||
src={props.src}
|
|
||||||
width={props.width}
|
|
||||||
height={props.height}
|
|
||||||
alt={props.alt}
|
|
||||||
/>
|
|
||||||
</picture>
|
</picture>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,7 +127,7 @@ function closestElement(
|
|||||||
* - However, this will break if elements become focusable/unfocusable during
|
* - However, this will break if elements become focusable/unfocusable during
|
||||||
* their lifetime (this is generally a sign something is being done wrong).
|
* 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);
|
const [intent, setIntent] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 React from 'react';
|
||||||
import { FunImage } from './FunImage';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Button
|
* Button
|
||||||
@@ -10,9 +9,8 @@ import { FunImage } from './FunImage';
|
|||||||
|
|
||||||
export type FunItemButtonProps = Readonly<{
|
export type FunItemButtonProps = Readonly<{
|
||||||
'aria-label': string;
|
'aria-label': string;
|
||||||
'aria-describedby'?: string;
|
|
||||||
tabIndex: number;
|
tabIndex: number;
|
||||||
onClick: (event: MouseEvent) => void;
|
onClick: (event: ReactMouseEvent) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -22,7 +20,6 @@ export function FunItemButton(props: FunItemButtonProps): JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
className="FunItem__Button"
|
className="FunItem__Button"
|
||||||
aria-label={props['aria-label']}
|
aria-label={props['aria-label']}
|
||||||
aria-describedby={props['aria-describedby']}
|
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
tabIndex={props.tabIndex}
|
tabIndex={props.tabIndex}
|
||||||
>
|
>
|
||||||
@@ -30,48 +27,3 @@ export function FunItemButton(props: FunItemButtonProps): JSX.Element {
|
|||||||
</button>
|
</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
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
import { VisuallyHidden } from 'react-aria';
|
||||||
|
import type { LocalizerType } from '../../../types/I18N';
|
||||||
|
|
||||||
export type FunSearchProps = Readonly<{
|
export type FunSearchProps = Readonly<{
|
||||||
|
i18n: LocalizerType;
|
||||||
'aria-label': string;
|
'aria-label': string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
searchInput: string;
|
searchInput: string;
|
||||||
@@ -10,7 +13,8 @@ export type FunSearchProps = Readonly<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function FunSearch(props: FunSearchProps): JSX.Element {
|
export function FunSearch(props: FunSearchProps): JSX.Element {
|
||||||
const { onSearchInputChange } = props;
|
const { i18n, onSearchInputChange } = props;
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
event => {
|
event => {
|
||||||
onSearchInputChange(event.target.value);
|
onSearchInputChange(event.target.value);
|
||||||
@@ -18,6 +22,10 @@ export function FunSearch(props: FunSearchProps): JSX.Element {
|
|||||||
[onSearchInputChange]
|
[onSearchInputChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
onSearchInputChange('');
|
||||||
|
}, [onSearchInputChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="FunSearch__Container">
|
<div className="FunSearch__Container">
|
||||||
<div className="FunSearch__Icon" />
|
<div className="FunSearch__Icon" />
|
||||||
@@ -29,6 +37,19 @@ export function FunSearch(props: FunSearchProps): JSX.Element {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={props.placeholder}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -279,8 +279,6 @@ export function FunSubNavImage(props: FunSubNavImageProps): JSX.Element {
|
|||||||
src={props.src}
|
src={props.src}
|
||||||
width={26}
|
width={26}
|
||||||
height={26}
|
height={26}
|
||||||
// presentational
|
|
||||||
alt=""
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,7 @@ import React, { useCallback } from 'react';
|
|||||||
import type { Key } from 'react-aria';
|
import type { Key } from 'react-aria';
|
||||||
import { useId } from 'react-aria';
|
import { useId } from 'react-aria';
|
||||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
|
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
|
||||||
import type { FunPickerTabKey } from '../FunConstants';
|
import type { FunPickerTabKey } from '../constants';
|
||||||
|
|
||||||
export type FunTabsProps = Readonly<{
|
export type FunTabsProps = Readonly<{
|
||||||
value: FunPickerTabKey;
|
value: FunPickerTabKey;
|
||||||
|
@@ -60,17 +60,3 @@ export const FunEmojisSectionOrder: ReadonlyArray<
|
|||||||
EmojiPickerCategory.Symbols,
|
EmojiPickerCategory.Symbols,
|
||||||
EmojiPickerCategory.Flags,
|
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
|
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 */
|
/** @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.None, 0],
|
||||||
[EmojiSkinTone.Type1, 1],
|
[EmojiSkinTone.Type1, 1],
|
||||||
[EmojiSkinTone.Type2, 2],
|
[EmojiSkinTone.Type2, 2],
|
||||||
@@ -63,24 +79,22 @@ export const SKIN_TONE_TO_NUMBER: Map<EmojiSkinTone, number> = new Map([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
/** @deprecated We should use `EmojiSkinTone` everywhere */
|
/** @deprecated We should use `EmojiSkinTone` everywhere */
|
||||||
export const NUMBER_TO_SKIN_TONE: Map<number, EmojiSkinTone> = new Map([
|
export const KEY_TO_EMOJI_SKIN_TONE = new Map<string, EmojiSkinTone>([
|
||||||
[0, EmojiSkinTone.None],
|
['1F3FB', EmojiSkinTone.Type1],
|
||||||
[1, EmojiSkinTone.Type1],
|
['1F3FC', EmojiSkinTone.Type2],
|
||||||
[2, EmojiSkinTone.Type2],
|
['1F3FD', EmojiSkinTone.Type3],
|
||||||
[3, EmojiSkinTone.Type3],
|
['1F3FE', EmojiSkinTone.Type4],
|
||||||
[4, EmojiSkinTone.Type4],
|
['1F3FF', EmojiSkinTone.Type5],
|
||||||
[5, EmojiSkinTone.Type5],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type EmojiSkinToneVariant = Exclude<EmojiSkinTone, EmojiSkinTone.None>;
|
/** @deprecated We should use `EmojiSkinTone` everywhere */
|
||||||
|
export const EMOJI_SKIN_TONE_TO_KEY: Map<EmojiSkinTone, string> = new Map([
|
||||||
const KeyToEmojiSkinTone: Record<string, EmojiSkinToneVariant> = {
|
[EmojiSkinTone.Type1, '1F3FB'],
|
||||||
'1F3FB': EmojiSkinTone.Type1,
|
[EmojiSkinTone.Type2, '1F3FC'],
|
||||||
'1F3FC': EmojiSkinTone.Type2,
|
[EmojiSkinTone.Type3, '1F3FD'],
|
||||||
'1F3FD': EmojiSkinTone.Type3,
|
[EmojiSkinTone.Type4, '1F3FE'],
|
||||||
'1F3FE': EmojiSkinTone.Type4,
|
[EmojiSkinTone.Type5, '1F3FF'],
|
||||||
'1F3FF': EmojiSkinTone.Type5,
|
]);
|
||||||
};
|
|
||||||
|
|
||||||
export type EmojiParentKey = string & { EmojiParentKey: never };
|
export type EmojiParentKey = string & { EmojiParentKey: never };
|
||||||
export type EmojiVariantKey = string & { EmojiVariantKey: never };
|
export type EmojiVariantKey = string & { EmojiVariantKey: never };
|
||||||
@@ -94,18 +108,17 @@ export type EmojiEnglishShortName = string & { EmojiEnglishShortName: never };
|
|||||||
export type EmojiVariantData = Readonly<{
|
export type EmojiVariantData = Readonly<{
|
||||||
key: EmojiVariantKey;
|
key: EmojiVariantKey;
|
||||||
value: EmojiVariantValue;
|
value: EmojiVariantValue;
|
||||||
|
valueNonqualified: EmojiVariantValue | null;
|
||||||
sheetX: number;
|
sheetX: number;
|
||||||
sheetY: number;
|
sheetY: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type EmojiDefaultSkinToneVariants = Record<
|
type EmojiDefaultSkinToneVariants = Record<EmojiSkinTone, EmojiVariantKey>;
|
||||||
EmojiSkinToneVariant,
|
|
||||||
EmojiVariantKey
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type EmojiParentData = Readonly<{
|
export type EmojiParentData = Readonly<{
|
||||||
key: EmojiParentKey;
|
key: EmojiParentKey;
|
||||||
value: EmojiParentValue;
|
value: EmojiParentValue;
|
||||||
|
valueNonqualified: EmojiParentValue | null;
|
||||||
unicodeCategory: EmojiUnicodeCategory;
|
unicodeCategory: EmojiUnicodeCategory;
|
||||||
pickerCategory: EmojiPickerCategory | null;
|
pickerCategory: EmojiPickerCategory | null;
|
||||||
defaultVariant: EmojiVariantKey;
|
defaultVariant: EmojiVariantKey;
|
||||||
@@ -124,6 +137,7 @@ export type EmojiParentData = Readonly<{
|
|||||||
|
|
||||||
const RawEmojiSkinToneSchema = z.object({
|
const RawEmojiSkinToneSchema = z.object({
|
||||||
unified: z.string(),
|
unified: z.string(),
|
||||||
|
non_qualified: z.union([z.string(), z.null()]),
|
||||||
sheet_x: z.number(),
|
sheet_x: z.number(),
|
||||||
sheet_y: z.number(),
|
sheet_y: z.number(),
|
||||||
has_img_apple: z.boolean(),
|
has_img_apple: z.boolean(),
|
||||||
@@ -133,6 +147,7 @@ const RawEmojiSkinToneMapSchema = z.record(z.string(), RawEmojiSkinToneSchema);
|
|||||||
|
|
||||||
const RawEmojiSchema = z.object({
|
const RawEmojiSchema = z.object({
|
||||||
unified: z.string(),
|
unified: z.string(),
|
||||||
|
non_qualified: z.union([z.string(), z.null()]),
|
||||||
category: z.string(),
|
category: z.string(),
|
||||||
sort_order: z.number(),
|
sort_order: z.number(),
|
||||||
sheet_x: z.number(),
|
sheet_x: z.number(),
|
||||||
@@ -282,6 +297,9 @@ const EMOJI_INDEX: EmojiIndex = {
|
|||||||
function addParent(parent: EmojiParentData, rank: number) {
|
function addParent(parent: EmojiParentData, rank: number) {
|
||||||
EMOJI_INDEX.parentByKey[parent.key] = parent;
|
EMOJI_INDEX.parentByKey[parent.key] = parent;
|
||||||
EMOJI_INDEX.parentKeysByValue[parent.value] = parent.key;
|
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.parentKeysByName[parent.englishShortNameDefault] = parent.key;
|
||||||
EMOJI_INDEX.unicodeCategories[parent.unicodeCategory].push(parent.key);
|
EMOJI_INDEX.unicodeCategories[parent.unicodeCategory].push(parent.key);
|
||||||
if (parent.pickerCategory != null) {
|
if (parent.pickerCategory != null) {
|
||||||
@@ -306,6 +324,9 @@ function addVariant(parentKey: EmojiParentKey, variant: EmojiVariantData) {
|
|||||||
EMOJI_INDEX.parentKeysByVariantKeys[variant.key] = parentKey;
|
EMOJI_INDEX.parentKeysByVariantKeys[variant.key] = parentKey;
|
||||||
EMOJI_INDEX.variantByKey[variant.key] = variant;
|
EMOJI_INDEX.variantByKey[variant.key] = variant;
|
||||||
EMOJI_INDEX.variantKeysByValue[variant.value] = variant.key;
|
EMOJI_INDEX.variantKeysByValue[variant.value] = variant.key;
|
||||||
|
if (variant.valueNonqualified) {
|
||||||
|
EMOJI_INDEX.variantKeysByValue[variant.valueNonqualified] = variant.key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const rawEmoji of RAW_EMOJI_DATA) {
|
for (const rawEmoji of RAW_EMOJI_DATA) {
|
||||||
@@ -314,6 +335,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
|
|||||||
const defaultVariant: EmojiVariantData = {
|
const defaultVariant: EmojiVariantData = {
|
||||||
key: toEmojiVariantKey(rawEmoji.unified),
|
key: toEmojiVariantKey(rawEmoji.unified),
|
||||||
value: toEmojiVariantValue(rawEmoji.unified),
|
value: toEmojiVariantValue(rawEmoji.unified),
|
||||||
|
valueNonqualified:
|
||||||
|
rawEmoji.non_qualified != null
|
||||||
|
? toEmojiVariantValue(rawEmoji.non_qualified)
|
||||||
|
: null,
|
||||||
sheetX: rawEmoji.sheet_x,
|
sheetX: rawEmoji.sheet_x,
|
||||||
sheetY: rawEmoji.sheet_y,
|
sheetY: rawEmoji.sheet_y,
|
||||||
};
|
};
|
||||||
@@ -331,6 +356,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
|
|||||||
const skinToneVariant: EmojiVariantData = {
|
const skinToneVariant: EmojiVariantData = {
|
||||||
key: variantKey,
|
key: variantKey,
|
||||||
value: toEmojiVariantValue(value.unified),
|
value: toEmojiVariantValue(value.unified),
|
||||||
|
valueNonqualified:
|
||||||
|
rawEmoji.non_qualified != null
|
||||||
|
? toEmojiVariantValue(rawEmoji.non_qualified)
|
||||||
|
: null,
|
||||||
sheetX: value.sheet_x,
|
sheetX: value.sheet_x,
|
||||||
sheetY: value.sheet_y,
|
sheetY: value.sheet_y,
|
||||||
};
|
};
|
||||||
@@ -339,7 +368,7 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result: Partial<EmojiDefaultSkinToneVariants> = {};
|
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 one = map.get(key) ?? null;
|
||||||
const two = map.get(`${key}-${key}`) ?? null;
|
const two = map.get(`${key}-${key}`) ?? null;
|
||||||
const variantKey = one ?? two;
|
const variantKey = one ?? two;
|
||||||
@@ -356,6 +385,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
|
|||||||
const parent: EmojiParentData = {
|
const parent: EmojiParentData = {
|
||||||
key: toEmojiParentKey(rawEmoji.unified),
|
key: toEmojiParentKey(rawEmoji.unified),
|
||||||
value: toEmojiParentValue(rawEmoji.unified),
|
value: toEmojiParentValue(rawEmoji.unified),
|
||||||
|
valueNonqualified:
|
||||||
|
rawEmoji.non_qualified != null
|
||||||
|
? toEmojiParentValue(rawEmoji.non_qualified)
|
||||||
|
: null,
|
||||||
unicodeCategory: toEmojiUnicodeCategory(rawEmoji.category),
|
unicodeCategory: toEmojiUnicodeCategory(rawEmoji.category),
|
||||||
pickerCategory: toEmojiPickerCategory(rawEmoji.category),
|
pickerCategory: toEmojiPickerCategory(rawEmoji.category),
|
||||||
defaultVariant: defaultVariant.key,
|
defaultVariant: defaultVariant.key,
|
||||||
@@ -404,16 +437,6 @@ export function getEmojiVariantByKey(key: EmojiVariantKey): EmojiVariantData {
|
|||||||
return data;
|
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(
|
export function getEmojiParentKeyByValue(
|
||||||
value: EmojiParentValue
|
value: EmojiParentValue
|
||||||
): EmojiParentKey {
|
): EmojiParentKey {
|
||||||
@@ -492,6 +515,14 @@ export function* _allEmojiVariantKeys(): Iterable<EmojiVariantKey> {
|
|||||||
yield* Object.keys(EMOJI_INDEX.variantByKey) as Array<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 {
|
export function emojiVariantConstant(input: string): EmojiVariantData {
|
||||||
strictAssert(
|
strictAssert(
|
||||||
isEmojiVariantValue(input),
|
isEmojiVariantValue(input),
|
||||||
|
@@ -10,7 +10,7 @@ import type {
|
|||||||
} from './tenor';
|
} from './tenor';
|
||||||
import { tenor, isTenorTailCursor } 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';
|
const ATTACHMENT_CONTENT_FORMAT: TenorContentFormat = 'mp4';
|
||||||
|
|
||||||
function toGif(result: TenorResponseResult): GifType {
|
function toGif(result: TenorResponseResult): GifType {
|
||||||
@@ -40,7 +40,7 @@ export type GifsPaginated = Readonly<{
|
|||||||
gifs: ReadonlyArray<GifType>;
|
gifs: ReadonlyArray<GifType>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export async function fetchFeatured(
|
export async function fetchGifsFeatured(
|
||||||
limit: number,
|
limit: number,
|
||||||
cursor: TenorNextCursor | null,
|
cursor: TenorNextCursor | null,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
@@ -48,7 +48,7 @@ export async function fetchFeatured(
|
|||||||
const response = await tenor(
|
const response = await tenor(
|
||||||
'v2/featured',
|
'v2/featured',
|
||||||
{
|
{
|
||||||
// contentfilter: 'medium',
|
contentfilter: 'low',
|
||||||
media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
|
media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
|
||||||
limit,
|
limit,
|
||||||
pos: cursor ?? undefined,
|
pos: cursor ?? undefined,
|
||||||
@@ -61,7 +61,7 @@ export async function fetchFeatured(
|
|||||||
return { next, gifs };
|
return { next, gifs };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSearch(
|
export async function fetchGifsSearch(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
cursor: TenorNextCursor | null,
|
cursor: TenorNextCursor | null,
|
||||||
@@ -71,7 +71,7 @@ export async function fetchSearch(
|
|||||||
'v2/search',
|
'v2/search',
|
||||||
{
|
{
|
||||||
q: query,
|
q: query,
|
||||||
contentfilter: 'medium',
|
contentfilter: 'low',
|
||||||
media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
|
media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
|
||||||
limit,
|
limit,
|
||||||
pos: cursor ?? undefined,
|
pos: cursor ?? undefined,
|
||||||
|
@@ -52,7 +52,7 @@ export function useInfiniteQuery<Query, Page>(
|
|||||||
const [edition, setEdition] = useState(0);
|
const [edition, setEdition] = useState(0);
|
||||||
const [state, setState] = useState<InfiniteQueryState<Query, Page>>({
|
const [state, setState] = useState<InfiniteQueryState<Query, Page>>({
|
||||||
query: options.query,
|
query: options.query,
|
||||||
pending: false,
|
pending: true,
|
||||||
rejected: false,
|
rejected: false,
|
||||||
pages: [],
|
pages: [],
|
||||||
hasNextPage: false,
|
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 type { Simplify } from 'type-fest';
|
||||||
import { strictAssert } from '../../../util/assert';
|
import { strictAssert } from '../../../util/assert';
|
||||||
import { parseUnknown } from '../../../util/schemas';
|
import { parseUnknown } from '../../../util/schemas';
|
||||||
|
import { fetchInSegments } from './segments';
|
||||||
|
|
||||||
const BASE_URL = 'https://tenor.googleapis.com/v2';
|
const BASE_URL = 'https://tenor.googleapis.com/v2';
|
||||||
const API_KEY = 'AIzaSyBt6SUfSsCQic2P2VkNkLjsGI7HGWZI95g';
|
const API_KEY = 'AIzaSyBt6SUfSsCQic2P2VkNkLjsGI7HGWZI95g';
|
||||||
@@ -215,23 +216,18 @@ export async function tenor<Path extends keyof TenorEndpoints>(
|
|||||||
url.searchParams.set(key, param);
|
url.searchParams.set(key, param);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await messaging.server.fetchJsonViaProxy(
|
const response = await messaging.server.fetchJsonViaProxy({
|
||||||
url.toString(),
|
method: 'GET',
|
||||||
signal
|
url: url.toString(),
|
||||||
);
|
signal,
|
||||||
|
});
|
||||||
const result = parseUnknown(schema, response.data);
|
const result = parseUnknown(schema, response.data);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function tenorDownload(
|
export function tenorDownload(
|
||||||
tenorCdnUrl: string,
|
tenorCdnUrl: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<Uint8Array> {
|
): Promise<Blob> {
|
||||||
const { messaging } = window.textsecure;
|
return fetchInSegments(tenorCdnUrl, signal);
|
||||||
strictAssert(messaging, 'Missing window.textsecure.messaging');
|
|
||||||
const response = await messaging.server.fetchBytesViaProxy(
|
|
||||||
tenorCdnUrl,
|
|
||||||
signal
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
@@ -7,6 +7,7 @@ import {
|
|||||||
getEmojiParentKeyByEnglishShortName,
|
getEmojiParentKeyByEnglishShortName,
|
||||||
isEmojiEnglishShortName,
|
isEmojiEnglishShortName,
|
||||||
} from './data/emojis';
|
} from './data/emojis';
|
||||||
|
import type { GifsPaginated } from './data/gifs';
|
||||||
|
|
||||||
function getEmoji(input: string): EmojiParentKey {
|
function getEmoji(input: string): EmojiParentKey {
|
||||||
strictAssert(
|
strictAssert(
|
||||||
@@ -50,3 +51,29 @@ export const MOCK_RECENT_EMOJIS: ReadonlyArray<EmojiParentKey> = [
|
|||||||
getEmoji('open_mouth'),
|
getEmoji('open_mouth'),
|
||||||
getEmoji('zipper_mouth_face'),
|
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 type { LocalizerType } from '../../../types/I18N';
|
||||||
import { strictAssert } from '../../../util/assert';
|
import { strictAssert } from '../../../util/assert';
|
||||||
import { missingCaseError } from '../../../util/missingCaseError';
|
import { missingCaseError } from '../../../util/missingCaseError';
|
||||||
import type { FunEmojisSection } from '../FunConstants';
|
import type { FunEmojisSection } from '../constants';
|
||||||
import { FunEmojisSectionOrder, FunSectionCommon } from '../FunConstants';
|
import { FunEmojisSectionOrder, FunSectionCommon } from '../constants';
|
||||||
import {
|
import {
|
||||||
FunGridCell,
|
FunGridCell,
|
||||||
FunGridContainer,
|
FunGridContainer,
|
||||||
@@ -37,10 +37,10 @@ import type {
|
|||||||
EmojiVariantKey,
|
EmojiVariantKey,
|
||||||
} from '../data/emojis';
|
} from '../data/emojis';
|
||||||
import {
|
import {
|
||||||
|
emojiParentKeyConstant,
|
||||||
EmojiPickerCategory,
|
EmojiPickerCategory,
|
||||||
emojiVariantConstant,
|
emojiVariantConstant,
|
||||||
getEmojiParentByKey,
|
getEmojiParentByKey,
|
||||||
getEmojiParentKeyByValueUnsafe,
|
|
||||||
getEmojiPickerCategoryParentKeys,
|
getEmojiPickerCategoryParentKeys,
|
||||||
getEmojiVariantByParentKeyAndSkinTone,
|
getEmojiVariantByParentKeyAndSkinTone,
|
||||||
isEmojiParentKey,
|
isEmojiParentKey,
|
||||||
@@ -55,8 +55,8 @@ import type {
|
|||||||
GridSectionNode,
|
GridSectionNode,
|
||||||
} from '../virtual/useFunVirtualGrid';
|
} from '../virtual/useFunVirtualGrid';
|
||||||
import { useFunVirtualGrid } from '../virtual/useFunVirtualGrid';
|
import { useFunVirtualGrid } from '../virtual/useFunVirtualGrid';
|
||||||
import { SkinTonesListBox } from '../base/FunSkinTones';
|
import { FunSkinTonesList } from '../FunSkinTones';
|
||||||
import { FunEmoji } from '../FunEmoji';
|
import { FunStaticEmoji } from '../FunEmoji';
|
||||||
import { useFunContext } from '../FunProvider';
|
import { useFunContext } from '../FunProvider';
|
||||||
import { FunResults, FunResultsHeader } from '../base/FunResults';
|
import { FunResults, FunResultsHeader } from '../base/FunResults';
|
||||||
|
|
||||||
@@ -252,6 +252,7 @@ export function FunPanelEmojis({
|
|||||||
return (
|
return (
|
||||||
<FunPanel>
|
<FunPanel>
|
||||||
<FunSearch
|
<FunSearch
|
||||||
|
i18n={i18n}
|
||||||
searchInput={searchInput}
|
searchInput={searchInput}
|
||||||
onSearchInputChange={onSearchInputChange}
|
onSearchInputChange={onSearchInputChange}
|
||||||
placeholder={i18n('icu:FunPanelEmojis__SearchLabel')}
|
placeholder={i18n('icu:FunPanelEmojis__SearchLabel')}
|
||||||
@@ -342,11 +343,9 @@ export function FunPanelEmojis({
|
|||||||
<FunResults aria-busy={false}>
|
<FunResults aria-busy={false}>
|
||||||
<FunResultsHeader>
|
<FunResultsHeader>
|
||||||
{i18n('icu:FunPanelEmojis__SearchResults__EmptyHeading')}{' '}
|
{i18n('icu:FunPanelEmojis__SearchResults__EmptyHeading')}{' '}
|
||||||
<FunEmoji
|
<FunStaticEmoji
|
||||||
size={16}
|
size={16}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
// For presentation only
|
|
||||||
aria-label=""
|
|
||||||
emoji={emojiVariantConstant('\u{1F641}')}
|
emoji={emojiVariantConstant('\u{1F641}')}
|
||||||
/>
|
/>
|
||||||
</FunResultsHeader>
|
</FunResultsHeader>
|
||||||
@@ -386,8 +385,8 @@ export function FunPanelEmojis({
|
|||||||
{section.id === EmojiPickerCategory.SmileysAndPeople && (
|
{section.id === EmojiPickerCategory.SmileysAndPeople && (
|
||||||
<SectionSkinTonePopover
|
<SectionSkinTonePopover
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
skinTone={fun.defaultEmojiSkinTone}
|
skinTone={fun.emojiSkinToneDefault}
|
||||||
onSelectSkinTone={fun.onChangeDefaultEmojiSkinTone}
|
onSelectSkinTone={fun.onEmojiSkinToneDefaultChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FunGridHeader>
|
</FunGridHeader>
|
||||||
@@ -405,7 +404,7 @@ export function FunPanelEmojis({
|
|||||||
rowIndex={row.rowIndex}
|
rowIndex={row.rowIndex}
|
||||||
cells={row.cells}
|
cells={row.cells}
|
||||||
focusedCellKey={focusedCellKey}
|
focusedCellKey={focusedCellKey}
|
||||||
defaultEmojiSkinTone={fun.defaultEmojiSkinTone}
|
emojiSkinToneDefault={fun.emojiSkinToneDefault}
|
||||||
onPressEmoji={handlePressEmoji}
|
onPressEmoji={handlePressEmoji}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -426,7 +425,7 @@ type RowProps = Readonly<{
|
|||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
cells: ReadonlyArray<CellLayoutNode>;
|
cells: ReadonlyArray<CellLayoutNode>;
|
||||||
focusedCellKey: CellKey | null;
|
focusedCellKey: CellKey | null;
|
||||||
defaultEmojiSkinTone: EmojiSkinTone;
|
emojiSkinToneDefault: EmojiSkinTone;
|
||||||
onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void;
|
onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -446,7 +445,7 @@ const Row = memo(function Row(props: RowProps): JSX.Element {
|
|||||||
rowIndex={cell.rowIndex}
|
rowIndex={cell.rowIndex}
|
||||||
colIndex={cell.colIndex}
|
colIndex={cell.colIndex}
|
||||||
isTabbable={isTabbable}
|
isTabbable={isTabbable}
|
||||||
defaultEmojiSkinTone={props.defaultEmojiSkinTone}
|
emojiSkinToneDefault={props.emojiSkinToneDefault}
|
||||||
onPressEmoji={props.onPressEmoji}
|
onPressEmoji={props.onPressEmoji}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -461,7 +460,7 @@ type CellProps = Readonly<{
|
|||||||
colIndex: number;
|
colIndex: number;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
isTabbable: boolean;
|
isTabbable: boolean;
|
||||||
defaultEmojiSkinTone: EmojiSkinTone;
|
emojiSkinToneDefault: EmojiSkinTone;
|
||||||
onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void;
|
onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -478,8 +477,8 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
|
|||||||
|
|
||||||
const skinTone = useMemo(() => {
|
const skinTone = useMemo(() => {
|
||||||
// TODO(jamie): Need to implement emoji-specific skin tone preferences
|
// TODO(jamie): Need to implement emoji-specific skin tone preferences
|
||||||
return props.defaultEmojiSkinTone;
|
return props.emojiSkinToneDefault;
|
||||||
}, [props.defaultEmojiSkinTone]);
|
}, [props.emojiSkinToneDefault]);
|
||||||
|
|
||||||
const emojiVariant = useMemo(() => {
|
const emojiVariant = useMemo(() => {
|
||||||
return getEmojiVariantByParentKeyAndSkinTone(emojiParent.key, skinTone);
|
return getEmojiVariantByParentKeyAndSkinTone(emojiParent.key, skinTone);
|
||||||
@@ -509,12 +508,7 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
|
|||||||
aria-label={emojiParent.englishShortNameDefault}
|
aria-label={emojiParent.englishShortNameDefault}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<FunEmoji
|
<FunStaticEmoji role="presentation" size={32} emoji={emojiVariant} />
|
||||||
role="presentation"
|
|
||||||
aria-label=""
|
|
||||||
size={32}
|
|
||||||
emoji={emojiVariant}
|
|
||||||
/>
|
|
||||||
</FunItemButton>
|
</FunItemButton>
|
||||||
</FunGridCell>
|
</FunGridCell>
|
||||||
);
|
);
|
||||||
@@ -555,8 +549,9 @@ function SectionSkinTonePopover(
|
|||||||
<FunGridHeaderPopoverHeader>
|
<FunGridHeaderPopoverHeader>
|
||||||
{i18n('icu:FunPanelEmojis__SkinTonePicker__ChooseDefaultLabel')}
|
{i18n('icu:FunPanelEmojis__SkinTonePicker__ChooseDefaultLabel')}
|
||||||
</FunGridHeaderPopoverHeader>
|
</FunGridHeaderPopoverHeader>
|
||||||
<SkinTonesListBox
|
<FunSkinTonesList
|
||||||
emoji={getEmojiParentKeyByValueUnsafe('\u{270B}')}
|
i18n={i18n}
|
||||||
|
emoji={emojiParentKeyConstant('\u{270B}')}
|
||||||
skinTone={props.skinTone}
|
skinTone={props.skinTone}
|
||||||
onSelectSkinTone={handleSelectSkinTone}
|
onSelectSkinTone={handleSelectSkinTone}
|
||||||
/>
|
/>
|
||||||
|
@@ -11,9 +11,9 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { VisuallyHidden } from 'react-aria';
|
import { useId, VisuallyHidden } from 'react-aria';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import { FunItemButton, FunItemGif } from '../base/FunItem';
|
import { FunItemButton } from '../base/FunItem';
|
||||||
import { FunPanel } from '../base/FunPanel';
|
import { FunPanel } from '../base/FunPanel';
|
||||||
import { FunScroller } from '../base/FunScroller';
|
import { FunScroller } from '../base/FunScroller';
|
||||||
import { FunSearch } from '../base/FunSearch';
|
import { FunSearch } from '../base/FunSearch';
|
||||||
@@ -24,8 +24,8 @@ import {
|
|||||||
FunSubNavListBoxItem,
|
FunSubNavListBoxItem,
|
||||||
} from '../base/FunSubNav';
|
} from '../base/FunSubNav';
|
||||||
import { FunWaterfallContainer, FunWaterfallItem } from '../base/FunWaterfall';
|
import { FunWaterfallContainer, FunWaterfallItem } from '../base/FunWaterfall';
|
||||||
import type { FunGifsSection } from '../FunConstants';
|
import type { FunGifsSection } from '../constants';
|
||||||
import { FunGifsCategory, FunSectionCommon } from '../FunConstants';
|
import { FunGifsCategory, FunSectionCommon } from '../constants';
|
||||||
import { FunKeyboard } from '../keyboard/FunKeyboard';
|
import { FunKeyboard } from '../keyboard/FunKeyboard';
|
||||||
import type { WaterfallKeyboardState } from '../keyboard/WaterfallKeyboardDelegate';
|
import type { WaterfallKeyboardState } from '../keyboard/WaterfallKeyboardDelegate';
|
||||||
import { WaterfallKeyboardDelegate } from '../keyboard/WaterfallKeyboardDelegate';
|
import { WaterfallKeyboardDelegate } from '../keyboard/WaterfallKeyboardDelegate';
|
||||||
@@ -33,9 +33,7 @@ import { useInfiniteQuery } from '../data/infinite';
|
|||||||
import { missingCaseError } from '../../../util/missingCaseError';
|
import { missingCaseError } from '../../../util/missingCaseError';
|
||||||
import { strictAssert } from '../../../util/assert';
|
import { strictAssert } from '../../../util/assert';
|
||||||
import type { GifsPaginated } from '../data/gifs';
|
import type { GifsPaginated } from '../data/gifs';
|
||||||
import { fetchFeatured, fetchSearch } from '../data/gifs';
|
|
||||||
import { drop } from '../../../util/drop';
|
import { drop } from '../../../util/drop';
|
||||||
import { tenorDownload } from '../data/tenor';
|
|
||||||
import { useFunContext } from '../FunProvider';
|
import { useFunContext } from '../FunProvider';
|
||||||
import {
|
import {
|
||||||
FunResults,
|
FunResults,
|
||||||
@@ -44,8 +42,21 @@ import {
|
|||||||
FunResultsHeader,
|
FunResultsHeader,
|
||||||
FunResultsSpinner,
|
FunResultsSpinner,
|
||||||
} from '../base/FunResults';
|
} from '../base/FunResults';
|
||||||
import { FunEmoji } from '../FunEmoji';
|
import { FunStaticEmoji } from '../FunEmoji';
|
||||||
import { emojiVariantConstant } from '../data/emojis';
|
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 MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||||
const FunGifBlobCache = new LRUCache<string, Blob>({
|
const FunGifBlobCache = new LRUCache<string, Blob>({
|
||||||
@@ -95,7 +106,12 @@ type GifsQuery = Readonly<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type FunGifSelection = Readonly<{
|
export type FunGifSelection = Readonly<{
|
||||||
attachmentMedia: GifMediaType;
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type FunPanelGifsProps = Readonly<{
|
export type FunPanelGifsProps = Readonly<{
|
||||||
@@ -115,6 +131,9 @@ export function FunPanelGifs({
|
|||||||
selectedGifsSection,
|
selectedGifsSection,
|
||||||
onChangeSelectedSelectGifsSection,
|
onChangeSelectedSelectGifsSection,
|
||||||
recentGifs,
|
recentGifs,
|
||||||
|
fetchGifsFeatured,
|
||||||
|
fetchGifsSearch,
|
||||||
|
fetchGif,
|
||||||
} = fun;
|
} = fun;
|
||||||
|
|
||||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -142,6 +161,14 @@ export function FunPanelGifs({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
debouncedQuery.searchQuery === searchQuery &&
|
||||||
|
debouncedQuery.selectedSection === selectedGifsSection
|
||||||
|
) {
|
||||||
|
// don't update twice
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const query: GifsQuery = {
|
const query: GifsQuery = {
|
||||||
selectedSection: selectedGifsSection,
|
selectedSection: selectedGifsSection,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
@@ -158,7 +185,7 @@ export function FunPanelGifs({
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
}, [searchQuery, selectedGifsSection]);
|
}, [debouncedQuery, searchQuery, selectedGifsSection]);
|
||||||
|
|
||||||
const loader = useCallback(
|
const loader = useCallback(
|
||||||
async (
|
async (
|
||||||
@@ -170,7 +197,7 @@ export function FunPanelGifs({
|
|||||||
const limit = cursor != null ? 30 : 10;
|
const limit = cursor != null ? 30 : 10;
|
||||||
|
|
||||||
if (query.searchQuery !== '') {
|
if (query.searchQuery !== '') {
|
||||||
return fetchSearch(query.searchQuery, limit, cursor, signal);
|
return fetchGifsSearch(query.searchQuery, limit, cursor, signal);
|
||||||
}
|
}
|
||||||
strictAssert(
|
strictAssert(
|
||||||
query.selectedSection !== FunSectionCommon.SearchResults,
|
query.selectedSection !== FunSectionCommon.SearchResults,
|
||||||
@@ -180,33 +207,33 @@ export function FunPanelGifs({
|
|||||||
return { next: null, gifs: recentGifs };
|
return { next: null, gifs: recentGifs };
|
||||||
}
|
}
|
||||||
if (query.selectedSection === FunGifsCategory.Trending) {
|
if (query.selectedSection === FunGifsCategory.Trending) {
|
||||||
return fetchFeatured(limit, cursor, signal);
|
return fetchGifsFeatured(limit, cursor, signal);
|
||||||
}
|
}
|
||||||
if (query.selectedSection === FunGifsCategory.Celebrate) {
|
if (query.selectedSection === FunGifsCategory.Celebrate) {
|
||||||
return fetchSearch('celebrate', limit, cursor, signal);
|
return fetchGifsSearch('celebrate', limit, cursor, signal);
|
||||||
}
|
}
|
||||||
if (query.selectedSection === FunGifsCategory.Love) {
|
if (query.selectedSection === FunGifsCategory.Love) {
|
||||||
return fetchSearch('love', limit, cursor, signal);
|
return fetchGifsSearch('love', limit, cursor, signal);
|
||||||
}
|
}
|
||||||
if (query.selectedSection === FunGifsCategory.ThumbsUp) {
|
if (query.selectedSection === FunGifsCategory.ThumbsUp) {
|
||||||
return fetchSearch('thumbs-up', limit, cursor, signal);
|
return fetchGifsSearch('thumbs-up', limit, cursor, signal);
|
||||||
}
|
}
|
||||||
if (query.selectedSection === FunGifsCategory.Surprised) {
|
if (query.selectedSection === FunGifsCategory.Surprised) {
|
||||||
return fetchSearch('surprised', limit, cursor, signal);
|
return fetchGifsSearch('surprised', limit, cursor, signal);
|
||||||
}
|
}
|
||||||
if (query.selectedSection === FunGifsCategory.Excited) {
|
if (query.selectedSection === FunGifsCategory.Excited) {
|
||||||
return fetchSearch('excited', limit, cursor, signal);
|
return fetchGifsSearch('excited', limit, cursor, signal);
|
||||||
}
|
}
|
||||||
if (query.selectedSection === FunGifsCategory.Sad) {
|
if (query.selectedSection === FunGifsCategory.Sad) {
|
||||||
return fetchSearch('sad', limit, cursor, signal);
|
return fetchGifsSearch('sad', limit, cursor, signal);
|
||||||
}
|
}
|
||||||
if (query.selectedSection === FunGifsCategory.Angry) {
|
if (query.selectedSection === FunGifsCategory.Angry) {
|
||||||
return fetchSearch('angry', limit, cursor, signal);
|
return fetchGifsSearch('angry', limit, cursor, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw missingCaseError(query.selectedSection);
|
throw missingCaseError(query.selectedSection);
|
||||||
},
|
},
|
||||||
[recentGifs]
|
[recentGifs, fetchGifsSearch, fetchGifsFeatured]
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasNextPage = useCallback(
|
const hasNextPage = useCallback(
|
||||||
@@ -275,6 +302,7 @@ export function FunPanelGifs({
|
|||||||
scrollPaddingStart: 20,
|
scrollPaddingStart: 20,
|
||||||
scrollPaddingEnd: 20,
|
scrollPaddingEnd: 20,
|
||||||
getItemKey,
|
getItemKey,
|
||||||
|
initialOffset: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scroll back to top when query changes
|
// Scroll back to top when query changes
|
||||||
@@ -350,6 +378,7 @@ export function FunPanelGifs({
|
|||||||
return (
|
return (
|
||||||
<FunPanel>
|
<FunPanel>
|
||||||
<FunSearch
|
<FunSearch
|
||||||
|
i18n={i18n}
|
||||||
searchInput={searchInput}
|
searchInput={searchInput}
|
||||||
onSearchInputChange={handleSearchInputChange}
|
onSearchInputChange={handleSearchInputChange}
|
||||||
placeholder={i18n('icu:FunPanelGifs__SearchPlaceholder--Tenor')}
|
placeholder={i18n('icu:FunPanelGifs__SearchPlaceholder--Tenor')}
|
||||||
@@ -449,11 +478,9 @@ export function FunPanelGifs({
|
|||||||
{!queryState.pending && !queryState.rejected && (
|
{!queryState.pending && !queryState.rejected && (
|
||||||
<FunResultsHeader>
|
<FunResultsHeader>
|
||||||
{i18n('icu:FunPanelGifs__SearchResults__EmptyHeading')}{' '}
|
{i18n('icu:FunPanelGifs__SearchResults__EmptyHeading')}{' '}
|
||||||
<FunEmoji
|
<FunStaticEmoji
|
||||||
size={16}
|
size={16}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
// For presentation only
|
|
||||||
aria-label=""
|
|
||||||
emoji={emojiVariantConstant('\u{1F641}')}
|
emoji={emojiVariantConstant('\u{1F641}')}
|
||||||
/>
|
/>
|
||||||
</FunResultsHeader>
|
</FunResultsHeader>
|
||||||
@@ -461,34 +488,38 @@ export function FunPanelGifs({
|
|||||||
</FunResults>
|
</FunResults>
|
||||||
)}
|
)}
|
||||||
{count !== 0 && (
|
{count !== 0 && (
|
||||||
<FunKeyboard
|
<FunLightboxProvider containerRef={scrollerRef}>
|
||||||
scrollerRef={scrollerRef}
|
<GifsLightbox i18n={i18n} items={items} />
|
||||||
keyboard={keyboard}
|
<FunKeyboard
|
||||||
onStateChange={handleKeyboardStateChange}
|
scrollerRef={scrollerRef}
|
||||||
>
|
keyboard={keyboard}
|
||||||
<FunWaterfallContainer totalSize={virtualizer.getTotalSize()}>
|
onStateChange={handleKeyboardStateChange}
|
||||||
{virtualizer.getVirtualItems().map(item => {
|
>
|
||||||
const gif = items[item.index];
|
<FunWaterfallContainer totalSize={virtualizer.getTotalSize()}>
|
||||||
const key = String(item.key);
|
{virtualizer.getVirtualItems().map(item => {
|
||||||
const isTabbable =
|
const gif = items[item.index];
|
||||||
selectedItemKey != null
|
const key = String(item.key);
|
||||||
? key === selectedItemKey
|
const isTabbable =
|
||||||
: item.index === 0;
|
selectedItemKey != null
|
||||||
return (
|
? key === selectedItemKey
|
||||||
<Item
|
: item.index === 0;
|
||||||
key={key}
|
return (
|
||||||
gif={gif}
|
<Item
|
||||||
itemKey={key}
|
key={key}
|
||||||
itemHeight={item.size}
|
gif={gif}
|
||||||
itemOffset={item.start}
|
itemKey={key}
|
||||||
itemLane={item.lane}
|
itemHeight={item.size}
|
||||||
isTabbable={isTabbable}
|
itemOffset={item.start}
|
||||||
onPressGif={handlePressGif}
|
itemLane={item.lane}
|
||||||
/>
|
isTabbable={isTabbable}
|
||||||
);
|
onPressGif={handlePressGif}
|
||||||
})}
|
fetchGif={fetchGif}
|
||||||
</FunWaterfallContainer>
|
/>
|
||||||
</FunKeyboard>
|
);
|
||||||
|
})}
|
||||||
|
</FunWaterfallContainer>
|
||||||
|
</FunKeyboard>
|
||||||
|
</FunLightboxProvider>
|
||||||
)}
|
)}
|
||||||
</FunScroller>
|
</FunScroller>
|
||||||
</FunPanel>
|
</FunPanel>
|
||||||
@@ -503,16 +534,24 @@ const Item = memo(function Item(props: {
|
|||||||
itemLane: number;
|
itemLane: number;
|
||||||
isTabbable: boolean;
|
isTabbable: boolean;
|
||||||
onPressGif: (event: MouseEvent, gifSelection: FunGifSelection) => void;
|
onPressGif: (event: MouseEvent, gifSelection: FunGifSelection) => void;
|
||||||
|
fetchGif: typeof tenorDownload;
|
||||||
}) {
|
}) {
|
||||||
const { onPressGif } = props;
|
const { onPressGif, fetchGif } = props;
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
async (event: MouseEvent) => {
|
async (event: MouseEvent) => {
|
||||||
onPressGif(event, {
|
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]
|
[props.gif, onPressGif]
|
||||||
);
|
);
|
||||||
|
|
||||||
const descriptionId = `FunGifsPanelItem__GifDescription--${props.gif.id}`;
|
const descriptionId = `FunGifsPanelItem__GifDescription--${props.gif.id}`;
|
||||||
const [src, setSrc] = useState<string | null>(() => {
|
const [src, setSrc] = useState<string | null>(() => {
|
||||||
const cached = readGifMediaFromCache(props.gif.previewMedia);
|
const cached = readGifMediaFromCache(props.gif.previewMedia);
|
||||||
@@ -528,10 +567,16 @@ const Item = memo(function Item(props: {
|
|||||||
const { signal } = controller;
|
const { signal } = controller;
|
||||||
|
|
||||||
async function download() {
|
async function download() {
|
||||||
const bytes = await tenorDownload(props.gif.previewMedia.url, signal);
|
try {
|
||||||
const blob = new Blob([bytes]);
|
const bytes = await fetchGif(props.gif.previewMedia.url, signal);
|
||||||
saveGifMediaToCache(props.gif.previewMedia, blob);
|
const blob = new Blob([bytes]);
|
||||||
setSrc(URL.createObjectURL(blob));
|
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());
|
drop(download());
|
||||||
@@ -539,7 +584,7 @@ const Item = memo(function Item(props: {
|
|||||||
return () => {
|
return () => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [props.gif, src]);
|
}, [props.gif, src, fetchGif]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -559,15 +604,15 @@ const Item = memo(function Item(props: {
|
|||||||
>
|
>
|
||||||
<FunItemButton
|
<FunItemButton
|
||||||
aria-label={props.gif.title}
|
aria-label={props.gif.title}
|
||||||
aria-describedby={descriptionId}
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
tabIndex={props.isTabbable ? 0 : -1}
|
tabIndex={props.isTabbable ? 0 : -1}
|
||||||
>
|
>
|
||||||
{src != null && (
|
{src != null && (
|
||||||
<FunItemGif
|
<FunGif
|
||||||
src={src}
|
src={src}
|
||||||
width={props.gif.previewMedia.width}
|
width={props.gif.previewMedia.width}
|
||||||
height={props.gif.previewMedia.height}
|
height={props.gif.previewMedia.height}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<VisuallyHidden id={descriptionId}>
|
<VisuallyHidden id={descriptionId}>
|
||||||
@@ -577,3 +622,59 @@ const Item = memo(function Item(props: {
|
|||||||
</FunWaterfallItem>
|
</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';
|
} from '../../../state/ducks/stickers';
|
||||||
import type { LocalizerType } from '../../../types/I18N';
|
import type { LocalizerType } from '../../../types/I18N';
|
||||||
import { strictAssert } from '../../../util/assert';
|
import { strictAssert } from '../../../util/assert';
|
||||||
import type { FunStickersSection } from '../FunConstants';
|
import type { FunStickersSection } from '../constants';
|
||||||
import {
|
import {
|
||||||
FunSectionCommon,
|
FunSectionCommon,
|
||||||
FunStickersSectionBase,
|
FunStickersSectionBase,
|
||||||
toFunStickersPackSection,
|
toFunStickersPackSection,
|
||||||
} from '../FunConstants';
|
} from '../constants';
|
||||||
import {
|
import {
|
||||||
FunGridCell,
|
FunGridCell,
|
||||||
FunGridContainer,
|
FunGridContainer,
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
FunGridRowGroup,
|
FunGridRowGroup,
|
||||||
FunGridScrollerSection,
|
FunGridScrollerSection,
|
||||||
} from '../base/FunGrid';
|
} from '../base/FunGrid';
|
||||||
import { FunItemButton, FunItemSticker } from '../base/FunItem';
|
import { FunItemButton } from '../base/FunItem';
|
||||||
import { FunPanel } from '../base/FunPanel';
|
import { FunPanel } from '../base/FunPanel';
|
||||||
import { FunScroller } from '../base/FunScroller';
|
import { FunScroller } from '../base/FunScroller';
|
||||||
import { FunSearch } from '../base/FunSearch';
|
import { FunSearch } from '../base/FunSearch';
|
||||||
@@ -54,7 +54,15 @@ import type {
|
|||||||
import { useFunVirtualGrid } from '../virtual/useFunVirtualGrid';
|
import { useFunVirtualGrid } from '../virtual/useFunVirtualGrid';
|
||||||
import { useFunContext } from '../FunProvider';
|
import { useFunContext } from '../FunProvider';
|
||||||
import { FunResults, FunResultsHeader } from '../base/FunResults';
|
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_COLUMNS = 4;
|
||||||
const STICKER_GRID_CELL_WIDTH = 80;
|
const STICKER_GRID_CELL_WIDTH = 80;
|
||||||
@@ -267,6 +275,7 @@ export function FunPanelStickers({
|
|||||||
return (
|
return (
|
||||||
<FunPanel>
|
<FunPanel>
|
||||||
<FunSearch
|
<FunSearch
|
||||||
|
i18n={i18n}
|
||||||
searchInput={searchInput}
|
searchInput={searchInput}
|
||||||
onSearchInputChange={onSearchInputChange}
|
onSearchInputChange={onSearchInputChange}
|
||||||
placeholder={i18n('icu:FunPanelStickers__SearchPlaceholder')}
|
placeholder={i18n('icu:FunPanelStickers__SearchPlaceholder')}
|
||||||
@@ -325,73 +334,74 @@ export function FunPanelStickers({
|
|||||||
<FunResults aria-busy={false}>
|
<FunResults aria-busy={false}>
|
||||||
<FunResultsHeader>
|
<FunResultsHeader>
|
||||||
{i18n('icu:FunPanelStickers__SearchResults__EmptyHeading')}{' '}
|
{i18n('icu:FunPanelStickers__SearchResults__EmptyHeading')}{' '}
|
||||||
<FunEmoji
|
<FunStaticEmoji
|
||||||
size={16}
|
size={16}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
// For presentation only
|
|
||||||
aria-label=""
|
|
||||||
emoji={emojiVariantConstant('\u{1F641}')}
|
emoji={emojiVariantConstant('\u{1F641}')}
|
||||||
/>
|
/>
|
||||||
</FunResultsHeader>
|
</FunResultsHeader>
|
||||||
</FunResults>
|
</FunResults>
|
||||||
)}
|
)}
|
||||||
<FunKeyboard
|
<FunLightboxProvider containerRef={scrollerRef}>
|
||||||
scrollerRef={scrollerRef}
|
<StickersLightbox i18n={i18n} stickerLookup={stickerLookup} />
|
||||||
keyboard={keyboard}
|
<FunKeyboard
|
||||||
onStateChange={handleKeyboardStateChange}
|
scrollerRef={scrollerRef}
|
||||||
>
|
keyboard={keyboard}
|
||||||
<FunGridContainer
|
onStateChange={handleKeyboardStateChange}
|
||||||
totalSize={layout.totalHeight}
|
|
||||||
cellWidth={STICKER_GRID_CELL_WIDTH}
|
|
||||||
cellHeight={STICKER_GRID_CELL_HEIGHT}
|
|
||||||
columnCount={STICKER_GRID_COLUMNS}
|
|
||||||
>
|
>
|
||||||
{layout.sections.map(section => {
|
<FunGridContainer
|
||||||
return (
|
totalSize={layout.totalHeight}
|
||||||
<FunGridScrollerSection
|
cellWidth={STICKER_GRID_CELL_WIDTH}
|
||||||
key={section.key}
|
cellHeight={STICKER_GRID_CELL_HEIGHT}
|
||||||
id={section.id}
|
columnCount={STICKER_GRID_COLUMNS}
|
||||||
sectionOffset={section.sectionOffset}
|
>
|
||||||
sectionSize={section.sectionSize}
|
{layout.sections.map(section => {
|
||||||
>
|
return (
|
||||||
<FunGridHeader
|
<FunGridScrollerSection
|
||||||
id={section.header.key}
|
key={section.key}
|
||||||
headerOffset={section.header.headerOffset}
|
id={section.id}
|
||||||
headerSize={section.header.headerSize}
|
sectionOffset={section.sectionOffset}
|
||||||
|
sectionSize={section.sectionSize}
|
||||||
>
|
>
|
||||||
<FunGridHeaderText>
|
<FunGridHeader
|
||||||
{getTitleForSection(
|
id={section.header.key}
|
||||||
i18n,
|
headerOffset={section.header.headerOffset}
|
||||||
section.id as FunStickersSection,
|
headerSize={section.header.headerSize}
|
||||||
packsLookup
|
>
|
||||||
)}
|
<FunGridHeaderText>
|
||||||
</FunGridHeaderText>
|
{getTitleForSection(
|
||||||
</FunGridHeader>
|
i18n,
|
||||||
<FunGridRowGroup
|
section.id as FunStickersSection,
|
||||||
aria-labelledby={section.header.key}
|
packsLookup
|
||||||
colCount={section.colCount}
|
)}
|
||||||
rowCount={section.rowCount}
|
</FunGridHeaderText>
|
||||||
rowGroupOffset={section.rowGroup.rowGroupOffset}
|
</FunGridHeader>
|
||||||
rowGroupSize={section.rowGroup.rowGroupSize}
|
<FunGridRowGroup
|
||||||
>
|
aria-labelledby={section.header.key}
|
||||||
{section.rowGroup.rows.map(row => {
|
colCount={section.colCount}
|
||||||
return (
|
rowCount={section.rowCount}
|
||||||
<Row
|
rowGroupOffset={section.rowGroup.rowGroupOffset}
|
||||||
key={row.key}
|
rowGroupSize={section.rowGroup.rowGroupSize}
|
||||||
rowIndex={row.rowIndex}
|
>
|
||||||
cells={row.cells}
|
{section.rowGroup.rows.map(row => {
|
||||||
stickerLookup={stickerLookup}
|
return (
|
||||||
focusedCellKey={focusedCellKey}
|
<Row
|
||||||
onPressSticker={handlePressSticker}
|
key={row.key}
|
||||||
/>
|
rowIndex={row.rowIndex}
|
||||||
);
|
cells={row.cells}
|
||||||
})}
|
stickerLookup={stickerLookup}
|
||||||
</FunGridRowGroup>
|
focusedCellKey={focusedCellKey}
|
||||||
</FunGridScrollerSection>
|
onPressSticker={handlePressSticker}
|
||||||
);
|
/>
|
||||||
})}
|
);
|
||||||
</FunGridContainer>
|
})}
|
||||||
</FunKeyboard>
|
</FunGridRowGroup>
|
||||||
|
</FunGridScrollerSection>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</FunGridContainer>
|
||||||
|
</FunKeyboard>
|
||||||
|
</FunLightboxProvider>
|
||||||
</FunScroller>
|
</FunScroller>
|
||||||
</FunPanel>
|
</FunPanel>
|
||||||
);
|
);
|
||||||
@@ -467,8 +477,46 @@ const Cell = memo(function Cell(props: {
|
|||||||
aria-label={sticker.emoji ?? 'Sticker'}
|
aria-label={sticker.emoji ?? 'Sticker'}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<FunItemSticker src={sticker.url} />
|
<FunSticker role="presentation" src={sticker.url} size={68} />
|
||||||
</FunItemButton>
|
</FunItemButton>
|
||||||
</FunGridCell>
|
</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:
|
case LoadingState.Loading:
|
||||||
contents = <Spinner size="24px" svgSize="small" />;
|
contents = <Spinner size="24px" svgSize="small" />;
|
||||||
break;
|
break;
|
||||||
case LoadingState.LoadFailed:
|
case LoadingState.LoadFailed: {
|
||||||
switch (props.error) {
|
const { error } = props;
|
||||||
|
switch (error) {
|
||||||
case InstallScreenQRCodeError.Timeout:
|
case InstallScreenQRCodeError.Timeout:
|
||||||
contents = (
|
contents = (
|
||||||
<>
|
<>
|
||||||
@@ -229,9 +230,10 @@ function InstallScreenQrCode(
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw missingCaseError(props.error);
|
throw missingCaseError(error);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case LoadingState.Loaded:
|
case LoadingState.Loaded:
|
||||||
contents = <QRCodeImage i18n={i18n} link={props.value} />;
|
contents = <QRCodeImage i18n={i18n} link={props.value} />;
|
||||||
break;
|
break;
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user