Fun picker improvements

This commit is contained in:
Jamie Kyle
2025-03-26 12:35:32 -07:00
committed by GitHub
parent 427f91f903
commit b0653d06fe
142 changed files with 3581 additions and 1280 deletions

View File

@@ -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"

View File

@@ -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/**/*",

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;
}
} }
} }

View 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;
}

View File

@@ -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)
); );

View File

@@ -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';

View File

@@ -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;
}
} }

View 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;
}

View File

@@ -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,

View File

@@ -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;
}

View 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%);
}
}

View File

@@ -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);
}
} }

View File

@@ -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);
}
} }

View 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;
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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>
))} ))}
</> </>

View File

@@ -164,7 +164,7 @@ export function AnimatedEmoji({
y, y,
}} }}
> >
<Emojify sizeClass="medium" text={value} /> <Emojify fontSizeOverride={36} text={value} />
</animated.div> </animated.div>
); );
} }

View File

@@ -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')

View File

@@ -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: [],

View File

@@ -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}
/> />

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 &&

View File

@@ -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 {

View File

@@ -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();
}} }}

View 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')}
/>
);
}

View 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>
);
}

View File

@@ -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'),

View File

@@ -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>
); );

View File

@@ -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();
} }

View File

@@ -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>;

View File

@@ -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>

View File

@@ -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(

View File

@@ -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>

View File

@@ -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}
/> />
); );

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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>
); );
}); });

View File

@@ -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>;

View File

@@ -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}
/> />
)} )}
</>, </>,

View File

@@ -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'),

View File

@@ -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}

View File

@@ -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'),

View File

@@ -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>

View File

@@ -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>
) : ( ) : (

View File

@@ -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} />

View File

@@ -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} />;

View File

@@ -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') {

View File

@@ -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(

View File

@@ -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}

View File

@@ -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}
/> />
); );

View File

@@ -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'
)} )}

View File

@@ -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,
}); });

View File

@@ -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>
))} ))}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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="😂" />;
}

View File

@@ -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>
);
}
)
);

View File

@@ -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',

View File

@@ -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}
/> />

View File

@@ -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}
/> />

View File

@@ -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) && (

View File

@@ -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;
}
}); });
} }
}); });

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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}

View 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=""
/>
);
}

View 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>
);
}

View File

@@ -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>;

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>
); );
} }

View 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
/>
</>
);
}

View 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}
/>
);
}

View 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} />;
}

View 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>
);
});

View File

@@ -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(() => {

View File

@@ -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=""
/>
);
}

View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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=""
/> />
); );
} }

View File

@@ -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;

View File

@@ -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,
];

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,

View 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);
}

View File

@@ -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;
} }

View File

@@ -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,
},
};
}),
};

View File

@@ -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}
/> />

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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 }>;

View File

@@ -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