From 9346beca2462e2afb8c70939f28ddc9f5d06366a Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Mon, 7 Apr 2025 12:47:38 -0700 Subject: [PATCH] Add remaining features to fun picker --- ACKNOWLEDGMENTS.md | 204 +++++++ _locales/en/messages.json | 8 + images/icons/v3/more/more-circle-bold.svg | 1 + package.json | 1 + pnpm-lock.yaml | 3 + stylesheets/components/fun/Fun.scss | 3 + .../components/fun/FunErrorBoundary.scss | 11 + stylesheets/components/fun/FunGrid.scss | 4 +- stylesheets/components/fun/FunLightbox.scss | 2 + stylesheets/components/fun/FunPanel.scss | 27 +- .../components/fun/FunPanelEmojis.scss | 41 ++ .../components/fun/FunPanelStickers.scss | 62 ++ stylesheets/components/fun/FunScroller.scss | 2 +- stylesheets/components/fun/FunSearch.scss | 8 +- stylesheets/components/fun/FunSticker.scss | 1 + stylesheets/components/fun/FunSubNav.scss | 1 - ts/components/CallScreen.tsx | 8 +- ts/components/CompositionArea.tsx | 2 +- ts/components/CompositionInput.tsx | 2 +- .../CustomizingPreferredReactionsModal.tsx | 110 +++- ts/components/MediaEditor.tsx | 123 ++-- ts/components/ProfileEditor.tsx | 10 +- ts/components/ReactionPickerPicker.tsx | 19 +- ts/components/StoryViewer.tsx | 2 +- ts/components/StoryViewsNRepliesModal.tsx | 2 +- ts/components/conversation/ReactionPicker.tsx | 1 + ts/components/emoji/EmojiPicker.tsx | 14 +- ts/components/fun/FunEmojiPicker.stories.tsx | 2 + ts/components/fun/FunEmojiPicker.tsx | 15 +- ts/components/fun/FunPicker.stories.tsx | 1 + ts/components/fun/FunPicker.tsx | 35 +- ts/components/fun/FunProvider.tsx | 19 +- ts/components/fun/FunSkinTones.tsx | 4 +- ts/components/fun/FunSticker.tsx | 2 +- .../fun/FunStickerPicker.stories.tsx | 3 + ts/components/fun/FunStickerPicker.tsx | 18 +- ts/components/fun/base/FunErrorBoundary.tsx | 64 +++ ts/components/fun/base/FunPanel.tsx | 24 + ts/components/fun/base/FunSearch.tsx | 17 + ts/components/fun/constants.tsx | 8 + ts/components/fun/data/infinite.ts | 18 +- ts/components/fun/mocks.tsx | 1 + ts/components/fun/panels/FunPanelEmojis.tsx | 407 ++++++++------ ts/components/fun/panels/FunPanelGifs.tsx | 285 +++++----- ts/components/fun/panels/FunPanelStickers.tsx | 529 ++++++++++++------ .../auto-substitute-ascii-emojis/index.tsx | 8 +- ts/state/ducks/preferredReactions.ts | 7 +- ts/state/selectors/items.ts | 10 +- ts/state/smart/FunProvider.tsx | 11 + ts/test-both/state/selectors/items_test.ts | 4 +- ts/util/lint/exceptions.json | 7 + 51 files changed, 1512 insertions(+), 659 deletions(-) create mode 100644 images/icons/v3/more/more-circle-bold.svg create mode 100644 stylesheets/components/fun/FunErrorBoundary.scss create mode 100644 stylesheets/components/fun/FunPanelEmojis.scss create mode 100644 stylesheets/components/fun/FunPanelStickers.scss create mode 100644 ts/components/fun/base/FunErrorBoundary.tsx diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index c5f339a7db..ddbc8dc9fc 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -401,6 +401,210 @@ Signal Desktop makes use of the following open source projects. See the License for the specific language governing permissions and limitations under the License. +## @react-aria/interactions + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Adobe + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + ## @react-aria/utils License: Apache-2.0 diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5842534c59..1184f47c5e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3131,6 +3131,10 @@ "messageformat": "Flags", "description": "FunPicker > Emojis Panel > Section Title > Flags" }, + "icu:FunPanelEmojis__CustomizeReactionsButtonLabel": { + "messageformat": "Customize reactions", + "description": "FunPicker (when rendered in ReactionPicker) > Emojis Panel > Customize Reactions Button > Accessibility Label" + }, "icu:FunPanelStickers__SearchLabel": { "messageformat": "Search stickers", "description": "FunPicker > Stickers Panel > Search Input > Label" @@ -3155,6 +3159,10 @@ "messageformat": "Recently Used", "description": "FunPicker > Stickers Panel > Section Title > Recents" }, + "icu:FunPanelStickers__SectionTitle--Featured": { + "messageformat": "Featured", + "description": "FunPicker > Stickers Panel > Section Title > Featured" + }, "icu:FunPanelStickers__SearchResults__EmptyHeading": { "messageformat": "No stickers found", "description": "FunPicker > Stickers Panel > Search Results > Empty State > Heading" diff --git a/images/icons/v3/more/more-circle-bold.svg b/images/icons/v3/more/more-circle-bold.svg new file mode 100644 index 0000000000..29e498817e --- /dev/null +++ b/images/icons/v3/more/more-circle-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index eb80b823dd..311c435891 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "@indutny/simple-windows-notifications": "2.0.16", "@indutny/sneequals": "4.0.0", "@popperjs/core": "2.11.8", + "@react-aria/interactions": "3.23.0", "@react-aria/focus": "3.19.1", "@react-aria/utils": "3.25.3", "@react-spring/web": "9.7.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a3ee91cdf..d648939341 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@react-aria/focus': specifier: 3.19.1 version: 3.19.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@react-aria/interactions': + specifier: 3.23.0 + version: 3.23.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) '@react-aria/utils': specifier: 3.25.3 version: 3.25.3(react@17.0.2) diff --git a/stylesheets/components/fun/Fun.scss b/stylesheets/components/fun/Fun.scss index 9ed48085a6..75e73b8109 100644 --- a/stylesheets/components/fun/Fun.scss +++ b/stylesheets/components/fun/Fun.scss @@ -4,12 +4,15 @@ @use './FunButton.scss'; @use './FunConstants.scss'; @use './FunEmoji.scss'; +@use './FunErrorBoundary.scss'; @use './FunGif.scss'; @use './FunGrid.scss'; @use './FunImage.scss'; @use './FunItem.scss'; @use './FunLightbox.scss'; @use './FunPanel.scss'; +@use './FunPanelEmojis.scss'; +@use './FunPanelStickers.scss'; @use './FunResults.scss'; @use './FunPopover.scss'; @use './FunScroller.scss'; diff --git a/stylesheets/components/fun/FunErrorBoundary.scss b/stylesheets/components/fun/FunErrorBoundary.scss new file mode 100644 index 0000000000..3903ae1fc2 --- /dev/null +++ b/stylesheets/components/fun/FunErrorBoundary.scss @@ -0,0 +1,11 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use '../../mixins'; +@use '../../variables'; +@use './FunConstants.scss'; + +.FunErrorBoundary { + width: FunConstants.$Fun__PanelWidth; + height: FunConstants.$Fun__PanelHeight; +} diff --git a/stylesheets/components/fun/FunGrid.scss b/stylesheets/components/fun/FunGrid.scss index d64107ef55..6075fa3404 100644 --- a/stylesheets/components/fun/FunGrid.scss +++ b/stylesheets/components/fun/FunGrid.scss @@ -88,8 +88,8 @@ ); } -.FunGrid__HeaderIcon--Settings { - @include icon('../images/icons/v3/settings/settings.svg'); +.FunGrid__HeaderIcon--More { + @include icon('../images/icons/v3/more/more-circle-bold.svg'); } $popover-margin: 12px; diff --git a/stylesheets/components/fun/FunLightbox.scss b/stylesheets/components/fun/FunLightbox.scss index 83ef742230..0f52379419 100644 --- a/stylesheets/components/fun/FunLightbox.scss +++ b/stylesheets/components/fun/FunLightbox.scss @@ -16,6 +16,8 @@ } .FunLightbox__Dialog { + max-height: 80vh; + max-width: 80vh; pointer-events: none; filter: drop-shadow(0 4px 6px variables.$color-black-alpha-20); transition: all ease-out 400ms; diff --git a/stylesheets/components/fun/FunPanel.scss b/stylesheets/components/fun/FunPanel.scss index 0fea5938e7..6df299ee91 100644 --- a/stylesheets/components/fun/FunPanel.scss +++ b/stylesheets/components/fun/FunPanel.scss @@ -11,7 +11,28 @@ max-height: 100%; contain: size layout; grid-template: - 'FunSearch__Container' auto - 'FunScroller__Container' 1fr - 'FunSubNav__Container' auto; + 'FunPanel__Header' auto + 'FunPanel__Body' 1fr + 'FunPanel__Footer' auto; +} + +.FunPanel__Header { + grid-area: FunPanel__Header; + display: flex; + gap: 6px; + padding-block: 12px; + padding-inline: 12px; + + .FunTabs__TabPanelInner & { + padding-top: 0; + } +} + +.FunPanel__Body { + grid-area: FunPanel__Body; +} + +.FunPanel__Footer { + grid-area: FunPanel__Footer; + min-width: 0; } diff --git a/stylesheets/components/fun/FunPanelEmojis.scss b/stylesheets/components/fun/FunPanelEmojis.scss new file mode 100644 index 0000000000..bf77a631f8 --- /dev/null +++ b/stylesheets/components/fun/FunPanelEmojis.scss @@ -0,0 +1,41 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use '../../mixins'; +@use '../../variables'; + +.FunPanelEmojis__CustomizePreferredReactionsButton { + @include mixins.button-reset(); + & { + flex-shrink: 0; + display: flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border-radius: 9999px; + } + + &:hover, + &:focus { + background: light-dark(variables.$color-gray-02, variables.$color-gray-78); + } + + &:focus { + outline: none; + @include mixins.keyboard-mode { + outline: 2px solid variables.$color-ultramarine; + outline-offset: -2px; + } + } +} + +.FunPanelEmojis__CustomizePreferredReactionsButton__Icon { + width: 20px; + height: 20px; + + @include mixins.color-svg( + '../images/icons/v3/settings/settings-compact.svg', + light-dark(variables.$color-gray-75, variables.$color-gray-15) + ); +} diff --git a/stylesheets/components/fun/FunPanelStickers.scss b/stylesheets/components/fun/FunPanelStickers.scss new file mode 100644 index 0000000000..4f2d7b8f5c --- /dev/null +++ b/stylesheets/components/fun/FunPanelStickers.scss @@ -0,0 +1,62 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use '../../mixins'; +@use '../../variables'; + +.FunPanelStickers__TimeStickerWrapper { + width: 100%; + height: 100%; +} + +.FunPanelStickers__DigitalTimeSticker { + @include mixins.time-fonts(); + & { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: variables.$color-white; + font-size: 256px; + margin-top: -16px; + font-display: block; + } +} + +.FunPanelStickers__AnalogTimeSticker { + display: block; + position: relative; + width: 512px; + height: 512px; + background: url('../images/analog-time/Arabic.svg') center no-repeat; + background-size: contain; +} + +.FunPanelStickers__AnalogTimeSticker__HourHand { + display: block; + position: absolute; + top: 50%; + inset-inline-start: 50%; + width: 16px; + height: 112px; + margin-top: -112px; + margin-inline-start: -8px; + background: url('../images/analog-time/Arabic-hour.svg') center no-repeat; + transform-origin: 50% 100%; + transform: rotate(var(--fun-analog-time-sticker-hour)); +} + +.FunPanelStickers__AnalogTimeSticker__MinuteHand { + display: block; + position: absolute; + top: 50%; + inset-inline-start: 50%; + width: 16px; + height: 176px; + margin-top: -176px; + margin-inline-start: -8px; + background: url('../images/analog-time/Arabic-minute.svg') center no-repeat; + transform-origin: 50% 100%; + transform: rotate(var(--fun-analog-time-sticker-minute)); +} diff --git a/stylesheets/components/fun/FunScroller.scss b/stylesheets/components/fun/FunScroller.scss index 5355a474d7..c02b086ec1 100644 --- a/stylesheets/components/fun/FunScroller.scss +++ b/stylesheets/components/fun/FunScroller.scss @@ -5,9 +5,9 @@ @use '../../variables'; .FunScroller__Container { - grid-area: FunScroller__Container; position: relative; z-index: 0; + height: 100%; min-height: 0; &:has(.FunScroller__Viewport:focus)::before { diff --git a/stylesheets/components/fun/FunSearch.scss b/stylesheets/components/fun/FunSearch.scss index 0403b78dd2..12c429199e 100644 --- a/stylesheets/components/fun/FunSearch.scss +++ b/stylesheets/components/fun/FunSearch.scss @@ -13,14 +13,8 @@ $clear-icon-size: 16px; $input-padding-inline: 12px; .FunSearch__Container { - grid-area: FunSearch__Container; position: relative; - margin-block: 12px; - margin-inline: 12px; - - .FunTabs__TabPanelInner & { - margin-top: 0; - } + width: 100%; } .FunSearch__Icon { diff --git a/stylesheets/components/fun/FunSticker.scss b/stylesheets/components/fun/FunSticker.scss index ab43b3b3d8..05f2e5acfa 100644 --- a/stylesheets/components/fun/FunSticker.scss +++ b/stylesheets/components/fun/FunSticker.scss @@ -5,6 +5,7 @@ width: auto; height: auto; max-width: 100%; + max-height: 100%; border-radius: 4px; vertical-align: top; } diff --git a/stylesheets/components/fun/FunSubNav.scss b/stylesheets/components/fun/FunSubNav.scss index 94cb191e13..ea0a866ed5 100644 --- a/stylesheets/components/fun/FunSubNav.scss +++ b/stylesheets/components/fun/FunSubNav.scss @@ -14,7 +14,6 @@ $image-margin: calc(($button-size - $image-size) / 2); $image-radius: $button-radius - $image-margin; .FunSubNav__Container { - grid-area: FunSubNav__Container; min-width: 0; display: flex; align-items: center; diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 7b7b656756..e1cb9b8eea 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -371,7 +371,13 @@ export function CallScreen({ return noop; } return handleOutsideClick( - () => { + target => { + if ( + target instanceof Element && + target.closest('.FunPopover') != null + ) { + return true; + } setShowReactionPicker(false); return true; }, diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 6ad6948a7e..badcd4bcfd 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -209,7 +209,7 @@ export type OwnProps = Readonly<{ ) => void; onPickEmoji: (e: EmojiPickDataType) => void; - emojiSkinToneDefault: EmojiSkinTone; + emojiSkinToneDefault: EmojiSkinTone | null; // StickerButton installedPacks: ReadonlyArray; recentStickers: ReadonlyArray; diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 289ae0706a..ff5ed8ea6b 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -120,7 +120,7 @@ export type Props = Readonly<{ isFormattingEnabled: boolean; isActive: boolean; sendCounter: number; - emojiSkinToneDefault: EmojiSkinTone; + emojiSkinToneDefault: EmojiSkinTone | null; draftText: string | null; draftBodyRanges: HydratedBodyRangesType | null; moduleClassName?: string; diff --git a/ts/components/CustomizingPreferredReactionsModal.tsx b/ts/components/CustomizingPreferredReactionsModal.tsx index adcf1388a6..d1cdf95f81 100644 --- a/ts/components/CustomizingPreferredReactionsModal.tsx +++ b/ts/components/CustomizingPreferredReactionsModal.tsx @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { usePopper } from 'react-popper'; import { isEqual, noop } from 'lodash'; @@ -18,7 +18,10 @@ import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from '../reactions/const import { convertShortName } from './emoji/lib'; import { offsetDistanceModifier } from '../util/popperUtil'; import { handleOutsideClick } from '../util/handleOutsideClick'; -import type { EmojiSkinTone } from './fun/data/emojis'; +import { EmojiSkinTone, getEmojiVariantByKey } from './fun/data/emojis'; +import { FunEmojiPicker } from './fun/FunEmojiPicker'; +import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis'; +import { isFunPickerEnabled } from './fun/isFunPickerEnabled'; export type PropsType = { draftPreferredReactions: ReadonlyArray; @@ -28,7 +31,7 @@ export type PropsType = { originalPreferredReactions: ReadonlyArray; recentEmojis: ReadonlyArray; selectedDraftEmojiIndex: undefined | number; - emojiSkinToneDefault: EmojiSkinTone; + emojiSkinToneDefault: EmojiSkinTone | null; cancelCustomizePreferredReactionsModal(): unknown; deselectDraftEmoji(): unknown; @@ -58,6 +61,7 @@ export function CustomizingPreferredReactionsModal({ }: Readonly): JSX.Element { const [referenceElement, setReferenceElement] = useState(null); + const pickerRef = useRef(null); const [popperElement, setPopperElement] = useState( null ); @@ -80,12 +84,18 @@ export function CustomizingPreferredReactionsModal({ } return handleOutsideClick( - () => { + target => { + if ( + target instanceof Element && + target.closest('.FunPopover') != null + ) { + return true; + } deselectDraftEmoji(); return true; }, { - containerElements: [popperElement], + containerElements: [popperElement, pickerRef], name: 'CustomizingPreferredReactionsModal.draftEmoji', } ); @@ -99,7 +109,7 @@ export function CustomizingPreferredReactionsModal({ !isSaving && !isEqual( DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES.map(shortName => - convertShortName(shortName, emojiSkinToneDefault) + convertShortName(shortName, emojiSkinToneDefault ?? EmojiSkinTone.None) ), draftPreferredReactions ); @@ -149,31 +159,45 @@ export function CustomizingPreferredReactionsModal({ title={i18n('icu:CustomizingPreferredReactions__title')} modalFooter={footer} > -
+
- {draftPreferredReactions.map((emoji, index) => ( - { - selectDraftEmojiToBeReplaced(index); - }} - isSelected={index === selectedDraftEmojiIndex} - /> - ))} + {draftPreferredReactions.map((emoji, index) => { + return ( + { + selectDraftEmojiToBeReplaced(index); + }} + onDeselect={() => { + deselectDraftEmoji(); + }} + onSelectEmoji={emojiSelection => { + const emojiVariant = getEmojiVariantByKey( + emojiSelection.variantKey + ); + replaceSelectedDraftEmoji(emojiVariant.value); + }} + /> + ); + })} {hadSaveError ? i18n('icu:CustomizingPreferredReactions__had-save-error') : i18n('icu:CustomizingPreferredReactions__subtitle')}
- {isSomethingSelected && ( + {!isFunPickerEnabled() && isSomethingSelected && (
); } + +function CustomizingPreferredReactionsModalItem(props: { + emoji: string; + isSelected: boolean; + onSelect: () => void; + onDeselect: () => void; + onSelectEmoji: (emojiSelection: FunEmojiSelection) => void; +}) { + const { onDeselect } = props; + + const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); + + const handleEmojiPickerOpenChange = useCallback( + (open: boolean) => { + setEmojiPickerOpen(open); + if (!open) { + onDeselect(); + } + }, + [onDeselect] + ); + + const button = ( + + ); + + if (isFunPickerEnabled()) { + return ( + + {button} + + ); + } + return button; +} diff --git a/ts/components/MediaEditor.tsx b/ts/components/MediaEditor.tsx index f57252c09e..8d33746ead 100644 --- a/ts/components/MediaEditor.tsx +++ b/ts/components/MediaEditor.tsx @@ -68,6 +68,7 @@ import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis'; import { FunStickerPicker } from './fun/FunStickerPicker'; import type { FunStickerSelection } from './fun/panels/FunPanelStickers'; import { drop } from '../util/drop'; +import type { FunTimeStickerStyle } from './fun/constants'; export type MediaEditorResultType = Readonly<{ data: Uint8Array; @@ -268,6 +269,49 @@ export function MediaEditor({ [fabricCanvas, imageState.height, imageState.width] ); + const handlePickTimeSticker = useCallback( + (style: FunTimeStickerStyle) => { + if (!fabricCanvas) { + return; + } + + if (style === 'digital') { + const sticker = new MediaEditorFabricDigitalTimeSticker(Date.now()); + sticker.setPositionByOrigin( + new fabric.Point(imageState.width / 2, imageState.height / 2), + 'center', + 'center' + ); + sticker.setCoords(); + + fabricCanvas.add(sticker); + fabricCanvas.setActiveObject(sticker); + } + + if (style === 'analog') { + const sticker = new MediaEditorFabricAnalogTimeSticker(); + const STICKER_SIZE_RELATIVE_TO_CANVAS = 4; + const size = + Math.min(imageState.width, imageState.height) / + STICKER_SIZE_RELATIVE_TO_CANVAS; + + sticker.scaleToHeight(size); + sticker.setPositionByOrigin( + new fabric.Point(imageState.width / 2, imageState.height / 2), + 'center', + 'center' + ); + sticker.setCoords(); + + fabricCanvas.add(sticker); + fabricCanvas.setActiveObject(sticker); + } + + setEditMode(undefined); + }, + [fabricCanvas, imageState.height, imageState.width] + ); + const handleSelectSticker = useCallback( (stickerSelection: FunStickerSelection) => { handlePickSticker( @@ -1284,81 +1328,8 @@ export function MediaEditor({ i18n={i18n} installedPacks={installedPacks} knownPacks={[]} - onPickSticker={async (_packId, _stickerId, src: string) => { - if (!fabricCanvas) { - return; - } - - const img = await loadImage(src); - - const STICKER_SIZE_RELATIVE_TO_CANVAS = 4; - const size = - Math.min(imageState.width, imageState.height) / - STICKER_SIZE_RELATIVE_TO_CANVAS; - - const sticker = new MediaEditorFabricSticker(img); - sticker.scaleToHeight(size); - sticker.setPositionByOrigin( - new fabric.Point( - imageState.width / 2, - imageState.height / 2 - ), - 'center', - 'center' - ); - sticker.setCoords(); - - fabricCanvas.add(sticker); - fabricCanvas.setActiveObject(sticker); - setEditMode(undefined); - }} - onPickTimeSticker={(style: 'analog' | 'digital') => { - if (!fabricCanvas) { - return; - } - - if (style === 'digital') { - const sticker = new MediaEditorFabricDigitalTimeSticker( - Date.now() - ); - sticker.setPositionByOrigin( - new fabric.Point( - imageState.width / 2, - imageState.height / 2 - ), - 'center', - 'center' - ); - sticker.setCoords(); - - fabricCanvas.add(sticker); - fabricCanvas.setActiveObject(sticker); - } - - if (style === 'analog') { - const sticker = new MediaEditorFabricAnalogTimeSticker(); - const STICKER_SIZE_RELATIVE_TO_CANVAS = 4; - const size = - Math.min(imageState.width, imageState.height) / - STICKER_SIZE_RELATIVE_TO_CANVAS; - - sticker.scaleToHeight(size); - sticker.setPositionByOrigin( - new fabric.Point( - imageState.width / 2, - imageState.height / 2 - ), - 'center', - 'center' - ); - sticker.setCoords(); - - fabricCanvas.add(sticker); - fabricCanvas.setActiveObject(sticker); - } - - setEditMode(undefined); - }} + onPickSticker={handlePickSticker} + onPickTimeSticker={handlePickTimeSticker} receivedPacks={[]} recentStickers={recentStickers} showPickerHint={false} @@ -1370,6 +1341,8 @@ export function MediaEditor({ open={stickerPickerOpen} onOpenChange={handleStickerPickerOpenChange} onSelectSticker={handleSelectSticker} + showTimeStickers + onSelectTimeSticker={handlePickTimeSticker} placement="top" theme={ThemeType.dark} > diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index 8b19ff42b9..f66cfbc425 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -54,8 +54,9 @@ import { Tooltip, TooltipPlacement } from './Tooltip'; import { offsetDistanceModifier } from '../util/popperUtil'; import { useReducedMotion } from '../hooks/useReducedMotion'; import { FunStaticEmoji } from './fun/FunEmoji'; -import type { EmojiSkinTone, EmojiVariantKey } from './fun/data/emojis'; +import type { EmojiVariantKey } from './fun/data/emojis'; import { + EmojiSkinTone, getEmojiParentByKey, getEmojiParentKeyByEnglishShortName, getEmojiParentKeyByVariantKey, @@ -276,7 +277,10 @@ export function ProfileEditor({ // To make EmojiButton re-render less often const setAboutEmoji = useCallback( (ev: EmojiPickDataType) => { - const emojiData = getEmojiData(ev.shortName, emojiSkinToneDefault); + const emojiData = getEmojiData( + ev.shortName, + emojiSkinToneDefault ?? EmojiSkinTone.None + ); setStagedProfile(profileData => ({ ...profileData, aboutEmoji: unifiedToEmoji(emojiData.unified), @@ -549,7 +553,7 @@ export function ProfileEditor({ onClick={() => { const emojiData = getEmojiData( defaultBio.shortName, - emojiSkinToneDefault + emojiSkinToneDefault ?? EmojiSkinTone.None ); setStagedProfile(profileData => ({ diff --git a/ts/components/ReactionPickerPicker.tsx b/ts/components/ReactionPickerPicker.tsx index 9af285cfb7..ef10f4a3e1 100644 --- a/ts/components/ReactionPickerPicker.tsx +++ b/ts/components/ReactionPickerPicker.tsx @@ -5,6 +5,7 @@ import type { CSSProperties, ReactNode } from 'react'; import React, { forwardRef } from 'react'; import classNames from 'classnames'; +import { Button } from 'react-aria-components'; import type { LocalizerType } from '../types/Util'; import { FunStaticEmoji } from './fun/FunEmoji'; import { strictAssert } from '../util/assert'; @@ -39,26 +40,14 @@ export const ReactionPickerPickerEmojiButton = React.forwardRef< const emojiVariant = getEmojiVariantByKey(emojiVariantKey); return ( - + ); }); diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 9e729162b5..462060b424 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -115,7 +115,7 @@ export type PropsType = { setHasAllStoriesUnmuted: (isUnmuted: boolean) => unknown; showContactModal: (contactId: string, conversationId?: string) => void; showToast: ShowToastAction; - emojiSkinToneDefault: EmojiSkinTone; + emojiSkinToneDefault: EmojiSkinTone | null; story: StoryViewType; storyViewMode: StoryViewModeType; viewStory: ViewStoryActionCreatorType; diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index 4771461721..35e3a0e480 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -120,7 +120,7 @@ export type PropsType = { renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; replies: ReadonlyArray; showContactModal: (contactId: string, conversationId?: string) => void; - emojiSkinToneDefault: EmojiSkinTone; + emojiSkinToneDefault: EmojiSkinTone | null; sortedGroupMembers?: ReadonlyArray; views: ReadonlyArray; viewTarget: StoryViewTargetType; diff --git a/ts/components/conversation/ReactionPicker.tsx b/ts/components/conversation/ReactionPicker.tsx index 9d65f216cf..6dd8dbb0a5 100644 --- a/ts/components/conversation/ReactionPicker.tsx +++ b/ts/components/conversation/ReactionPicker.tsx @@ -154,6 +154,7 @@ export const ReactionPicker = React.forwardRef( onOpenChange={handleFunEmojiPickerOpenChange} onSelectEmoji={onSelectEmoji} theme={theme} + showCustomizePreferredReactionsButton > )} - + + + {!hasSearchQuery && ( + + + + {recentEmojis.length > 0 && ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + + {layout.sections.length === 0 && ( + + + {i18n('icu:FunPanelEmojis__SearchResults__EmptyHeading')}{' '} + + + + )} + {layout.sections.length > 0 && ( + + + {layout.sections.map(section => { + return ( + + + + {getTitleForSection( + i18n, + section.id as FunEmojisSection + )} + + {section.id === + EmojiPickerCategory.SmileysAndPeople && ( + + )} + + + {section.rowGroup.rows.map(row => { + return ( + + ); + })} + + + ); + })} + + + )} + + ); } @@ -426,7 +470,7 @@ type RowProps = Readonly<{ rowIndex: number; cells: ReadonlyArray; focusedCellKey: CellKey | null; - emojiSkinToneDefault: EmojiSkinTone; + emojiSkinToneDefault: EmojiSkinTone | null; onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void; }>; @@ -461,7 +505,7 @@ type CellProps = Readonly<{ colIndex: number; rowIndex: number; isTabbable: boolean; - emojiSkinToneDefault: EmojiSkinTone; + emojiSkinToneDefault: EmojiSkinTone | null; onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void; }>; @@ -478,7 +522,7 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element { const skinTone = useMemo(() => { // TODO(jamie): Need to implement emoji-specific skin tone preferences - return props.emojiSkinToneDefault; + return props.emojiSkinToneDefault ?? EmojiSkinTone.None; }, [props.emojiSkinToneDefault]); const emojiVariant = useMemo(() => { @@ -517,34 +561,31 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element { type SectionSkinTonePopoverProps = Readonly<{ i18n: LocalizerType; - skinTone: EmojiSkinTone; + open: boolean; + onOpenChange: (open: boolean) => void; + skinTone: EmojiSkinTone | null; onSelectSkinTone: (emojiSkinTone: EmojiSkinTone) => void; }>; function SectionSkinTonePopover( props: SectionSkinTonePopoverProps ): JSX.Element { - const { i18n, onSelectSkinTone } = props; - const [isOpen, setIsOpen] = useState(false); + const { i18n, onOpenChange, onSelectSkinTone } = props; const handleSelectSkinTone = useCallback( (emojiSkinTone: EmojiSkinTone) => { onSelectSkinTone(emojiSkinTone); - setIsOpen(false); + onOpenChange(false); }, - [onSelectSkinTone] + [onSelectSkinTone, onOpenChange] ); - const handleOpenChange = useCallback((open: boolean) => { - setIsOpen(open); - }, []); - return ( - + - + diff --git a/ts/components/fun/panels/FunPanelGifs.tsx b/ts/components/fun/panels/FunPanelGifs.tsx index 9d7372e6b8..a24dbcad0d 100644 --- a/ts/components/fun/panels/FunPanelGifs.tsx +++ b/ts/components/fun/panels/FunPanelGifs.tsx @@ -14,7 +14,12 @@ import React, { import { useId, VisuallyHidden } from 'react-aria'; import { LRUCache } from 'lru-cache'; import { FunItemButton } from '../base/FunItem'; -import { FunPanel } from '../base/FunPanel'; +import { + FunPanel, + FunPanelBody, + FunPanelFooter, + FunPanelHeader, +} from '../base/FunPanel'; import { FunScroller } from '../base/FunScroller'; import { FunSearch } from '../base/FunSearch'; import { @@ -376,151 +381,157 @@ export function FunPanelGifs({ return ( - + + + {visibleSelectedSection !== FunSectionCommon.SearchResults && ( - - - {recentGifs.length > 0 && ( + + + + {recentGifs.length > 0 && ( + + + + )} - + - )} - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + )} - - {count === 0 && ( - - {queryState.pending && ( - <> - - - - + + + {count === 0 && ( + + {queryState.pending && ( + <> + + + + + + {i18n('icu:FunPanelGifs__SearchResults__LoadingLabel')} + + + + )} + {queryState.rejected && ( + <> - {i18n('icu:FunPanelGifs__SearchResults__LoadingLabel')} + {i18n('icu:FunPanelGifs__SearchResults__ErrorHeading')} - - - )} - {queryState.rejected && ( - <> + + {i18n('icu:FunPanelGifs__SearchResults__ErrorRetryButton')} + + + )} + {!queryState.pending && !queryState.rejected && ( - {i18n('icu:FunPanelGifs__SearchResults__ErrorHeading')} + {i18n('icu:FunPanelGifs__SearchResults__EmptyHeading')}{' '} + - - {i18n('icu:FunPanelGifs__SearchResults__ErrorRetryButton')} - - - )} - {!queryState.pending && !queryState.rejected && ( - - {i18n('icu:FunPanelGifs__SearchResults__EmptyHeading')}{' '} - - - )} - - )} - {count !== 0 && ( - - - - - {virtualizer.getVirtualItems().map(item => { - const gif = items[item.index]; - const key = String(item.key); - const isTabbable = - selectedItemKey != null - ? key === selectedItemKey - : item.index === 0; - return ( - - ); - })} - - - - )} - + )} + + )} + {count !== 0 && ( + + + + + {virtualizer.getVirtualItems().map(item => { + const gif = items[item.index]; + const key = String(item.key); + const isTabbable = + selectedItemKey != null + ? key === selectedItemKey + : item.index === 0; + return ( + + ); + })} + + + + )} + + ); } diff --git a/ts/components/fun/panels/FunPanelStickers.tsx b/ts/components/fun/panels/FunPanelStickers.tsx index 5342653fde..1c9bbe2a9b 100644 --- a/ts/components/fun/panels/FunPanelStickers.tsx +++ b/ts/components/fun/panels/FunPanelStickers.tsx @@ -1,17 +1,29 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { MouseEvent } from 'react'; -import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; +import type { CSSProperties, MouseEvent } from 'react'; +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { StickerPackType, StickerType, } from '../../../state/ducks/stickers'; import type { LocalizerType } from '../../../types/I18N'; import { strictAssert } from '../../../util/assert'; -import type { FunStickersSection } from '../constants'; +import type { + FunStickersPackSection, + FunStickersSection, + FunTimeStickerStyle, +} from '../constants'; import { FunSectionCommon, FunStickersSectionBase, + FunTimeStickerStylesOrder, toFunStickersPackSection, } from '../constants'; import { @@ -24,7 +36,12 @@ import { FunGridScrollerSection, } from '../base/FunGrid'; import { FunItemButton } from '../base/FunItem'; -import { FunPanel } from '../base/FunPanel'; +import { + FunPanel, + FunPanelBody, + FunPanelFooter, + FunPanelHeader, +} from '../base/FunPanel'; import { FunScroller } from '../base/FunScroller'; import { FunSearch } from '../base/FunSearch'; import { @@ -63,6 +80,8 @@ import { useFunLightboxKey, } from '../base/FunLightbox'; import { FunSticker } from '../FunSticker'; +import { getAnalogTime } from '../../../util/getAnalogTime'; +import { getDateTimeFormatter } from '../../../util/formatTimestamp'; const STICKER_GRID_COLUMNS = 4; const STICKER_GRID_CELL_WIDTH = 80; @@ -72,16 +91,35 @@ const STICKER_GRID_SECTION_GAP = 20; const STICKER_GRID_HEADER_SIZE = 28; const STICKER_GRID_ROW_SIZE = STICKER_GRID_CELL_HEIGHT; -type StickerLookup = Record; +type StickerLookupItemSticker = { kind: 'sticker'; sticker: StickerType }; +type StickerLookupItemTimeSticker = { + kind: 'timeSticker'; + style: FunTimeStickerStyle; +}; +type StickerLookupItem = + | StickerLookupItemSticker + | StickerLookupItemTimeSticker; + +type StickerLookup = Record; type StickerPackLookup = Record; function getStickerId(sticker: StickerType): string { return `${sticker.packId}-${sticker.id}`; } +function getTimeStickerId(style: FunTimeStickerStyle): string { + return `_timeSticker:${style}`; +} + +function toStickerIds( + stickers: ReadonlyArray +): ReadonlyArray { + return stickers.map(sticker => getStickerId(sticker)); +} + function toGridSectionNode( section: FunStickersSection, - stickers: ReadonlyArray + values: ReadonlyArray ): GridSectionNode { return { id: section, @@ -89,8 +127,7 @@ function toGridSectionNode( header: { key: `header-${section}`, }, - cells: stickers.map(sticker => { - const value = getStickerId(sticker); + cells: values.map(value => { return { key: `cell-${section}-${value}`, value, @@ -113,7 +150,12 @@ function getTitleForSection( if (section === FunStickersSectionBase.StickersSetup) { return ''; } - const packId = section.replace(/^StickerPack:/, ''); + if (section === FunStickersSectionBase.Featured) { + return i18n('icu:FunPanelStickers__SectionTitle--Featured'); + } + // To assert the typescript type: + const stickerPackSection: FunStickersPackSection = section; + const packId = stickerPackSection.replace(/^StickerPack:/, ''); const pack = packs[packId]; strictAssert(pack != null, `Missing pack for ${packId}`); return pack.title; @@ -126,12 +168,16 @@ export type FunStickerSelection = Readonly<{ }>; export type FunPanelStickersProps = Readonly<{ + showTimeStickers: boolean; + onSelectTimeSticker?: (style: FunTimeStickerStyle) => void; onSelectSticker: (stickerSelection: FunStickerSelection) => void; onAddStickerPack: (() => void) | null; onClose: () => void; }>; export function FunPanelStickers({ + showTimeStickers, + onSelectTimeSticker, onSelectSticker, onAddStickerPack, onClose, @@ -160,13 +206,16 @@ export function FunPanelStickers({ const stickerLookup = useMemo(() => { const result: StickerLookup = {}; for (const sticker of recentStickers) { - result[getStickerId(sticker)] = sticker; + result[getStickerId(sticker)] = { kind: 'sticker', sticker }; } for (const installedStickerPack of installedStickerPacks) { for (const sticker of installedStickerPack.stickers) { - result[getStickerId(sticker)] = sticker; + result[getStickerId(sticker)] = { kind: 'sticker', sticker }; } } + for (const style of FunTimeStickerStylesOrder) { + result[getTimeStickerId(style)] = { kind: 'timeSticker', style }; + } return result; }, [recentStickers, installedStickerPacks]); @@ -192,23 +241,48 @@ export function FunPanelStickers({ }); return [ - toGridSectionNode(FunSectionCommon.SearchResults, matchingStickers), + toGridSectionNode( + FunSectionCommon.SearchResults, + toStickerIds(matchingStickers) + ), ]; } const result: Array = []; + if (showTimeStickers) { + result.push( + toGridSectionNode( + FunStickersSectionBase.Featured, + FunTimeStickerStylesOrder.map(style => { + return getTimeStickerId(style); + }) + ) + ); + } + if (recentStickers.length > 0) { - result.push(toGridSectionNode(FunSectionCommon.Recents, recentStickers)); + result.push( + toGridSectionNode( + FunSectionCommon.Recents, + toStickerIds(recentStickers) + ) + ); } for (const pack of installedStickerPacks) { const section = toFunStickersPackSection(pack); - result.push(toGridSectionNode(section, pack.stickers)); + result.push(toGridSectionNode(section, toStickerIds(pack.stickers))); } return result; - }, [recentStickers, installedStickerPacks, searchEmojis, searchQuery]); + }, [ + showTimeStickers, + recentStickers, + installedStickerPacks, + searchEmojis, + searchQuery, + ]); const [virtualizer, layout] = useFunVirtualGrid({ scrollerRef, @@ -273,137 +347,156 @@ export function FunPanelStickers({ [onSelectSticker, onClose] ); + const handlePressTimeSticker = useCallback( + (event: MouseEvent, style: FunTimeStickerStyle) => { + onSelectTimeSticker?.(style); + if (!(event.ctrlKey || event.metaKey)) { + onClose(); + } + }, + [onSelectTimeSticker, onClose] + ); + return ( - + + + {!hasSearchQuery && ( - - - {selectedStickersSection != null && ( - - {recentStickers.length > 0 && ( - - - - )} - {installedStickerPacks.map(installedStickerPack => { - return ( + + + + {selectedStickersSection != null && ( + + {recentStickers.length > 0 && ( - {installedStickerPack.cover && ( - + id={FunSectionCommon.Recents} + label={i18n( + 'icu:FunPanelStickers__SubNavCategoryLabel--Recents' )} + > + + )} + {installedStickerPacks.map(installedStickerPack => { + return ( + + {installedStickerPack.cover && ( + + )} + + ); + })} + + )} + + {onAddStickerPack != null && ( + + + + + + )} + + + )} + + + {layout.sections.length === 0 && ( + + + {i18n('icu:FunPanelStickers__SearchResults__EmptyHeading')}{' '} + + + + )} + + + + + {layout.sections.map(section => { + return ( + + + + {getTitleForSection( + i18n, + section.id as FunStickersSection, + packsLookup + )} + + + + {section.rowGroup.rows.map(row => { + return ( + + ); + })} + + ); })} - - )} - - {onAddStickerPack != null && ( - - - - - - )} - - )} - - {layout.sections.length === 0 && ( - - - {i18n('icu:FunPanelStickers__SearchResults__EmptyHeading')}{' '} - - - - )} - - - - - {layout.sections.map(section => { - return ( - - - - {getTitleForSection( - i18n, - section.id as FunStickersSection, - packsLookup - )} - - - - {section.rowGroup.rows.map(row => { - return ( - - ); - })} - - - ); - })} - - - - + + + + + ); } @@ -417,6 +510,7 @@ const Row = memo(function Row(props: { event: MouseEvent, stickerSelection: FunStickerSelection ) => void; + onPressTimeSticker: (event: MouseEvent, style: FunTimeStickerStyle) => void; }): JSX.Element { return ( @@ -435,6 +529,7 @@ const Row = memo(function Row(props: { stickerLookup={props.stickerLookup} isTabbable={isTabbable} onPressSticker={props.onPressSticker} + onPressTimeSticker={props.onPressTimeSticker} /> ); })} @@ -453,19 +548,24 @@ const Cell = memo(function Cell(props: { event: MouseEvent, stickerSelection: FunStickerSelection ) => void; + onPressTimeSticker: (event: MouseEvent, style: FunTimeStickerStyle) => void; }): JSX.Element { - const { onPressSticker } = props; - const sticker = props.stickerLookup[props.value]; + const { onPressSticker, onPressTimeSticker } = props; + const stickerLookupItem = props.stickerLookup[props.value]; const handleClick = useCallback( (event: MouseEvent) => { - onPressSticker(event, { - stickerPackId: sticker.packId, - stickerId: sticker.id, - stickerUrl: sticker.url, - }); + if (stickerLookupItem.kind === 'sticker') { + onPressSticker(event, { + stickerPackId: stickerLookupItem.sticker.packId, + stickerId: stickerLookupItem.sticker.id, + stickerUrl: stickerLookupItem.sticker.url, + }); + } else if (stickerLookupItem.kind === 'timeSticker') { + onPressTimeSticker(event, stickerLookupItem.style); + } }, - [sticker, onPressSticker] + [stickerLookupItem, onPressSticker, onPressTimeSticker] ); return ( @@ -476,10 +576,28 @@ const Cell = memo(function Cell(props: { > - + {stickerLookupItem.kind === 'sticker' && ( + + )} + {stickerLookupItem.kind === 'timeSticker' && + stickerLookupItem.style === 'digital' && ( + + )} + {stickerLookupItem.kind === 'timeSticker' && + stickerLookupItem.style === 'analog' && ( + + )} ); @@ -491,7 +609,7 @@ function StickersLightbox(props: { }) { const { i18n } = props; const key = useFunLightboxKey(); - const sticker = useMemo(() => { + const stickerLookupItem = useMemo(() => { if (key == null) { return null; } @@ -501,7 +619,7 @@ function StickersLightbox(props: { strictAssert(found, `Must have sticker for "${stickerId}"`); return found; }, [props.stickerLookup, key]); - if (sticker == null) { + if (stickerLookupItem == null) { return null; } return ( @@ -510,15 +628,112 @@ function StickersLightbox(props: { - + {stickerLookupItem.kind === 'sticker' && ( + + )} + {stickerLookupItem.kind === 'timeSticker' && + stickerLookupItem.style === 'digital' && ( + + )} + {stickerLookupItem.kind === 'timeSticker' && + stickerLookupItem.style === 'analog' && ( + + )} ); } + +function getDigitalTime() { + return getDateTimeFormatter({ hour: 'numeric', minute: 'numeric' }) + .formatToParts(Date.now()) + .filter(x => x.type !== 'dayPeriod') + .reduce((acc, { value }) => `${acc}${value}`, '') + .trim(); +} + +function DigitalTimeSticker(props: { size: number }) { + const [digitalTime, setDigitalTime] = useState(() => getDigitalTime()); + + useEffect(() => { + const interval = setInterval(() => { + setDigitalTime(getDigitalTime()); + }, 1000); + return () => { + clearInterval(interval); + }; + }, []); + + return ( + + + + {digitalTime} + + + + ); +} + +function AnalogTimeSticker(props: { size: number }) { + const [analogTime, setAnalogTime] = useState(() => { + return getAnalogTime(); + }); + + useEffect(() => { + const interval = setInterval(() => { + setAnalogTime(prev => { + const current = getAnalogTime(); + if (current.hour === prev.hour && current.minute === prev.minute) { + return prev; + } + return current; + }); + }, 1000); + return () => { + clearInterval(interval); + }; + }, []); + + return ( + + + + + + + + + ); +} diff --git a/ts/quill/auto-substitute-ascii-emojis/index.tsx b/ts/quill/auto-substitute-ascii-emojis/index.tsx index ce94c2a602..61282458bc 100644 --- a/ts/quill/auto-substitute-ascii-emojis/index.tsx +++ b/ts/quill/auto-substitute-ascii-emojis/index.tsx @@ -11,10 +11,10 @@ import { convertShortName, convertShortNameToData, } from '../../components/emoji/lib'; -import type { EmojiSkinTone } from '../../components/fun/data/emojis'; +import { EmojiSkinTone } from '../../components/fun/data/emojis'; export type AutoSubstituteAsciiEmojisOptions = { - emojiSkinToneDefault: EmojiSkinTone; + emojiSkinToneDefault: EmojiSkinTone | null; }; const emojiMap: Record = { @@ -103,7 +103,7 @@ export class AutoSubstituteAsciiEmojis { const emojiData = convertShortNameToData( emojiName, - this.options.emojiSkinToneDefault + this.options.emojiSkinToneDefault ?? EmojiSkinTone.None ); if (emojiData) { this.insertEmoji( @@ -123,7 +123,7 @@ export class AutoSubstituteAsciiEmojis { ): void { const emoji = convertShortName( emojiData.short_name, - this.options.emojiSkinToneDefault + this.options.emojiSkinToneDefault ?? EmojiSkinTone.None ); const delta = new Delta() .retain(index) diff --git a/ts/state/ducks/preferredReactions.ts b/ts/state/ducks/preferredReactions.ts index 3f24d703fb..0461f8a830 100644 --- a/ts/state/ducks/preferredReactions.ts +++ b/ts/state/ducks/preferredReactions.ts @@ -14,7 +14,7 @@ import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from '../../reactions/co import { getPreferredReactionEmoji } from '../../reactions/preferredReactionEmoji'; import { getEmojiSkinToneDefault } from '../selectors/items'; import { convertShortName } from '../../components/emoji/lib'; -import type { EmojiSkinTone } from '../../components/fun/data/emojis'; +import { EmojiSkinTone } from '../../components/fun/data/emojis'; // State @@ -124,7 +124,7 @@ function openCustomizePreferredReactionsModal(): ThunkAction< const state = getState(); const originalPreferredReactions = getPreferredReactionEmoji( getState().items.preferredReactionEmoji, - getEmojiSkinToneDefault(state) + getEmojiSkinToneDefault(state) ?? EmojiSkinTone.None ); dispatch({ type: OPEN_CUSTOMIZE_PREFERRED_REACTIONS_MODAL, @@ -149,7 +149,8 @@ function resetDraftEmoji(): ThunkAction< ResetDraftEmojiActionType > { return (dispatch, getState) => { - const emojiSkinTone = getEmojiSkinToneDefault(getState()); + const emojiSkinTone = + getEmojiSkinToneDefault(getState()) ?? EmojiSkinTone.None; dispatch({ type: RESET_DRAFT_EMOJI, payload: { emojiSkinTone } }); }; } diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 983f7f9685..3128f3f6ca 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -158,10 +158,8 @@ export const getCustomColors = createSelector( export const getEmojiSkinToneDefault = createSelector( getItems, - ({ emojiSkinToneDefault }: Readonly): EmojiSkinTone => - isValidEmojiSkinTone(emojiSkinToneDefault) - ? emojiSkinToneDefault - : EmojiSkinTone.None + ({ emojiSkinToneDefault }: Readonly): EmojiSkinTone | null => + isValidEmojiSkinTone(emojiSkinToneDefault) ? emojiSkinToneDefault : null ); export const getPreferredLeftPaneWidth = createSelector( @@ -178,11 +176,11 @@ export const getPreferredReactionEmoji = createSelector( getEmojiSkinToneDefault, ( state: Readonly, - emojiSkinToneDefault: EmojiSkinTone + emojiSkinToneDefault: EmojiSkinTone | null ): Array => getPreferredReactionEmojiFromStoredValue( state.preferredReactionEmoji, - emojiSkinToneDefault + emojiSkinToneDefault ?? EmojiSkinTone.None ) ); diff --git a/ts/state/smart/FunProvider.tsx b/ts/state/smart/FunProvider.tsx index 146c4a8651..8da541e171 100644 --- a/ts/state/smart/FunProvider.tsx +++ b/ts/state/smart/FunProvider.tsx @@ -28,6 +28,7 @@ import { fetchGifsSearch, } from '../../components/fun/data/gifs'; import { tenorDownload } from '../../components/fun/data/tenor'; +import { usePreferredReactionsActions } from '../ducks/preferredReactions'; export type SmartFunProviderProps = Readonly<{ children: ReactNode; @@ -46,6 +47,8 @@ export const SmartFunProvider = memo(function SmartFunProvider( const emojiSkinToneDefault = useSelector(getEmojiSkinToneDefault); const showStickerPickerHint = useSelector(getShowStickerPickerHint); const { removeItem, setEmojiSkinToneDefault } = useItemsActions(); + const { openCustomizePreferredReactionsModal } = + usePreferredReactionsActions(); // Translate recent emojis to keys const recentEmojisKeys = useMemo(() => { @@ -65,6 +68,11 @@ export const SmartFunProvider = memo(function SmartFunProvider( [setEmojiSkinToneDefault] ); + // Emojis + const handleOpenCustomizePreferredReactionsModal = useCallback(() => { + openCustomizePreferredReactionsModal(); + }, [openCustomizePreferredReactionsModal]); + // Stickers const handleClearStickerPickerHint = useCallback(() => { removeItem('showStickerPickerHint'); @@ -80,6 +88,9 @@ export const SmartFunProvider = memo(function SmartFunProvider( // Emojis emojiSkinToneDefault={emojiSkinToneDefault} onEmojiSkinToneDefaultChange={handleEmojiSkinToneDefaultChange} + onOpenCustomizePreferredReactionsModal={ + handleOpenCustomizePreferredReactionsModal + } // Stickers installedStickerPacks={installedStickerPacks} showStickerPickerHint={showStickerPickerHint} diff --git a/ts/test-both/state/selectors/items_test.ts b/ts/test-both/state/selectors/items_test.ts index 100be441da..844b402c12 100644 --- a/ts/test-both/state/selectors/items_test.ts +++ b/ts/test-both/state/selectors/items_test.ts @@ -42,7 +42,7 @@ describe('both/state/selectors/items', () => { }); describe('#getEmojiSkinTone', () => { - it('returns "None" if passed anything invalid', () => { + it('returns null if passed anything invalid', () => { [ // Invalid types undefined, @@ -60,7 +60,7 @@ describe('both/state/selectors/items', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- for testing ].forEach((emojiSkinToneDefault: any) => { const state = getRootState({ emojiSkinToneDefault }); - assert.strictEqual(getEmojiSkinToneDefault(state), EmojiSkinTone.None); + assert.strictEqual(getEmojiSkinToneDefault(state), null); }); }); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 0dddf4147d..21e1203128 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -1428,6 +1428,13 @@ "reasonCategory": "usageTrusted", "updated": "2022-08-19T17:09:38.534Z" }, + { + "rule": "React-useRef", + "path": "ts/components/CustomizingPreferredReactionsModal.tsx", + "line": " const pickerRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-04-07T19:15:28.908Z" + }, { "rule": "React-useRef", "path": "ts/components/DirectCallRemoteParticipant.tsx",