diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 95f058c043..d4155f7493 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -633,6 +633,210 @@ Signal Desktop makes use of the following open source projects. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## @react-types/shared + + 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. + ## @signalapp/quill-cjs Copyright (c) 2017-2024, Slab diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 4d3c17663c..1a1bb8f7e8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3084,10 +3084,18 @@ "messageformat": "No emoji found", "description": "FunPicker > Emojis Panel > Search Results > No Results Found (emoji plural)" }, + "icu:FunPanelEmojis__SkinTonePicker__SelectSkinToneForSelectedEmoji": { + "messageformat": "Select skin tone for {emojiName}", + "description": "FunPicker > Emojis Panel > Skin Tone Picker > Select skin tone for the selected emoji" + }, "icu:FunPanelEmojis__ChangeSkinToneButtonLabel": { "messageformat": "Change Skin Tone Preference", "description": "FunPicker > Emojis Panel > Change Skin Tone Button Label" }, + "icu:FunPanelEmojis__SkinTonePicker__LongPressAccessibilityDescription": { + "messageformat": "Long press to select skin tone", + "description": "FunPicker > Emojis Panel > Skin Tone Picker > Long press accessibility description" + }, "icu:FunPanelEmojis__SkinTonePicker__ChooseDefaultLabel": { "messageformat": "Choose your default skintone", "description": "FunPicker > Emojis Panel > Skin Tone Picker > Choose Default" @@ -3250,7 +3258,7 @@ }, "icu:FunSkinTones__ListItem--None": { "messageformat": "None", - "description": "(Deleted 2025/03/27) FunPicker > Emojis Panel > Skin Tone Picker > Skin Tone Option: None > Accessibility label" + "description": "FunPicker > Emojis Panel > Skin Tone Picker > Skin Tone Option: None > Accessibility label" }, "icu:FunSkinTones__ListItem--Light": { "messageformat": "Light skin tone", diff --git a/package.json b/package.json index 307bb4caba..2c9f13475b 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "@react-aria/focus": "3.19.1", "@react-aria/interactions": "3.23.0", "@react-aria/utils": "3.25.3", + "@react-types/shared": "3.27.0", "@react-spring/web": "9.7.5", "@signalapp/libsignal-client": "0.68.0", "@signalapp/quill-cjs": "2.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 669227c2c5..f0c6999114 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: '@react-spring/web': specifier: 9.7.5 version: 9.7.5(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@react-types/shared': + specifier: 3.27.0 + version: 3.27.0(react@17.0.2) '@signalapp/libsignal-client': specifier: 0.68.0 version: 0.68.0 diff --git a/stylesheets/components/DraftGifMessageSendModal.scss b/stylesheets/components/DraftGifMessageSendModal.scss index 4521efe65d..305aec9a3f 100644 --- a/stylesheets/components/DraftGifMessageSendModal.scss +++ b/stylesheets/components/DraftGifMessageSendModal.scss @@ -5,4 +5,5 @@ .DraftGifMessageSendModal__GifPreview { display: flex; justify-content: center; + padding-inline: 8px; } diff --git a/stylesheets/components/fun/FunEmoji.scss b/stylesheets/components/fun/FunEmoji.scss index 78c7563fc8..3e838bdd67 100644 --- a/stylesheets/components/fun/FunEmoji.scss +++ b/stylesheets/components/fun/FunEmoji.scss @@ -41,6 +41,7 @@ $emoji-sprite-sheet-grid-item-count: 62; z-index: 0; flex: none; content-visibility: auto; + vertical-align: top; } .FunStaticEmoji--Blot { diff --git a/stylesheets/components/fun/FunPanelEmojis.scss b/stylesheets/components/fun/FunPanelEmojis.scss index bf77a631f8..fb4b0712bf 100644 --- a/stylesheets/components/fun/FunPanelEmojis.scss +++ b/stylesheets/components/fun/FunPanelEmojis.scss @@ -3,6 +3,7 @@ @use '../../mixins'; @use '../../variables'; +@use './FunConstants.scss'; .FunPanelEmojis__CustomizePreferredReactionsButton { @include mixins.button-reset(); @@ -39,3 +40,45 @@ light-dark(variables.$color-gray-75, variables.$color-gray-15) ); } + +.FunPanelEmojis__CellPopover { + filter: drop-shadow(0 7px 18px variables.$color-black-alpha-30); + + &[data-placement='bottom'] { + .FunPanelEmojis__CellPopoverOverlayArrow svg { + transform: rotate(180deg); + } + } + + &[data-placement='right'] { + .FunPanelEmojis__CellPopoverOverlayArrow svg { + transform: rotate(90deg); + } + } + + &[data-placement='left'] { + .FunPanelEmojis__CellPopoverOverlayArrow svg { + transform: rotate(-90deg); + } + } +} + +.FunPanelEmojis__CellPopoverDialog { + padding: 8px; + border-radius: 8px; + background: FunConstants.$Fun__BgColor; + user-select: none; + + &:focus { + outline: none; + @include mixins.keyboard-mode { + outline: 2px solid variables.$color-ultramarine; + outline-offset: -2px; + } + } +} + +.FunPanelEmojis__CellPopoverOverlayArrow svg { + display: block; + fill: FunConstants.$Fun__BgColor; +} diff --git a/ts/components/DraftGifMessageSendModal.stories.tsx b/ts/components/DraftGifMessageSendModal.stories.tsx index a22c07fa17..d53d7b012d 100644 --- a/ts/components/DraftGifMessageSendModal.stories.tsx +++ b/ts/components/DraftGifMessageSendModal.stories.tsx @@ -80,12 +80,21 @@ export function Default(): JSX.Element { draftText="" draftBodyRanges={[]} gifSelection={{ - id: '', - title: '', - description: '', - url: '', - width: 640, - height: 640, + gif: { + id: '', + title: '', + description: '', + previewMedia: { + url: '', + width: 640, + height: 640, + }, + attachmentMedia: { + url: '', + width: 640, + height: 640, + }, + }, }} gifDownloadState={ file == null diff --git a/ts/components/DraftGifMessageSendModal.tsx b/ts/components/DraftGifMessageSendModal.tsx index c35780685e..cb5300d79e 100644 --- a/ts/components/DraftGifMessageSendModal.tsx +++ b/ts/components/DraftGifMessageSendModal.tsx @@ -81,14 +81,14 @@ export function DraftGifMessageSendModal( - {props.gifSelection.description} + {props.gifSelection.gif.description} { - if (props.recentGifs.length > 0) { - return FunSectionCommon.Recents; - } return FunGifsCategory.Trending; - }, [props.recentGifs]); + }, []); // Selected Sections const [selectedEmojisSection, setSelectedEmojisSection] = useState( diff --git a/ts/components/fun/FunSkinTones.tsx b/ts/components/fun/FunSkinTones.tsx index c1a96b710c..02aa86f3c0 100644 --- a/ts/components/fun/FunSkinTones.tsx +++ b/ts/components/fun/FunSkinTones.tsx @@ -44,7 +44,7 @@ export function FunSkinTonesList(props: FunSkinTonesListProps): JSX.Element { > void; - children: ReactNode; -}>; +export type FunItemButtonLongPressProps = Readonly< + | { + longPressAccessibilityDescription?: never; + onLongPress?: never; + } + | { + longPressAccessibilityDescription: string; + onLongPress: (event: LongPressEvent) => void; + } +>; + +export type FunItemButtonProps = Readonly< + { + 'aria-label': string; + tabIndex: number; + onPress: (event: PressEvent) => void; + onContextMenu?: (event: MouseEvent) => void; + children: ReactNode; + } & FunItemButtonLongPressProps +>; + +export const FunItemButton = forwardRef(function FunItemButton( + props: FunItemButtonProps, + ref: ForwardedRef +): JSX.Element { + const { pressProps } = usePress({ + onPress: props.onPress, + }); + + const { longPressProps } = useLongPress({ + isDisabled: props.onLongPress == null, + accessibilityDescription: props.longPressAccessibilityDescription, + onLongPress: props.onLongPress, + }); -export function FunItemButton(props: FunItemButtonProps): JSX.Element { return ( ); -} +}); diff --git a/ts/components/fun/panels/FunPanelEmojis.tsx b/ts/components/fun/panels/FunPanelEmojis.tsx index eb3fa39fb1..2bdda37a9f 100644 --- a/ts/components/fun/panels/FunPanelEmojis.tsx +++ b/ts/components/fun/panels/FunPanelEmojis.tsx @@ -2,7 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { MouseEvent } from 'react'; import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; -import { DialogTrigger } from 'react-aria-components'; +import { + Dialog, + DialogTrigger, + Heading, + OverlayArrow, + Popover, +} from 'react-aria-components'; +import { VisuallyHidden } from 'react-aria'; import type { LocalizerType } from '../../../types/I18N'; import { strictAssert } from '../../../util/assert'; import { missingCaseError } from '../../../util/missingCaseError'; @@ -247,16 +254,12 @@ export function FunPanelEmojis({ [onChangeSelectedEmojisSection, layout] ); - const handlePressEmoji = useCallback( - (event: MouseEvent, emojiSelection: FunEmojiSelection) => { - event.stopPropagation(); + const handleSelectEmoji = useCallback( + (emojiSelection: FunEmojiSelection) => { onFunSelectEmoji(emojiSelection); onSelectEmoji(emojiSelection); - // TODO(jamie): Quill is stealing focus updating the selection - if (!(event.ctrlKey || event.metaKey)) { - onClose(); - setFocusedCellKey(null); - } + onClose(); + setFocusedCellKey(null); }, [onFunSelectEmoji, onSelectEmoji, onClose] ); @@ -425,7 +428,7 @@ export function FunPanelEmojis({ {section.id === EmojiPickerCategory.SmileysAndPeople && ( - ); })} @@ -467,11 +474,13 @@ export function FunPanelEmojis({ } type RowProps = Readonly<{ + i18n: LocalizerType; rowIndex: number; cells: ReadonlyArray; focusedCellKey: CellKey | null; emojiSkinToneDefault: EmojiSkinTone | null; - onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void; + onSelectEmoji: (emojiSelection: FunEmojiSelection) => void; + onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void; }>; const Row = memo(function Row(props: RowProps): JSX.Element { @@ -485,13 +494,15 @@ const Row = memo(function Row(props: RowProps): JSX.Element { return ( ); })} @@ -500,19 +511,32 @@ const Row = memo(function Row(props: RowProps): JSX.Element { }); type CellProps = Readonly<{ + i18n: LocalizerType; value: string; cellKey: CellKey; colIndex: number; rowIndex: number; isTabbable: boolean; emojiSkinToneDefault: EmojiSkinTone | null; - onPressEmoji: (event: MouseEvent, emojiSelection: FunEmojiSelection) => void; + onSelectEmoji: (emojiSelection: FunEmojiSelection) => void; + onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void; }>; const Cell = memo(function Cell(props: CellProps): JSX.Element { - const { onPressEmoji } = props; + const { + i18n, + emojiSkinToneDefault, + onSelectEmoji, + onEmojiSkinToneDefaultChange, + } = props; const emojiLocalizer = useFunEmojiLocalizer(); + const popoverTriggerRef = useRef(null); + const [popoverOpen, setPopoverOpen] = useState(false); + const handlePopoverOpenChange = useCallback((open: boolean) => { + setPopoverOpen(open); + }, []); + const emojiParent = useMemo(() => { strictAssert( isEmojiParentKey(props.value), @@ -521,27 +545,77 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element { return getEmojiParentByKey(props.value); }, [props.value]); + const emojiHasSkinToneVariants = useMemo(() => { + return emojiParent.defaultSkinToneVariants != null; + }, [emojiParent.defaultSkinToneVariants]); + const skinTone = useMemo(() => { - // TODO(jamie): Need to implement emoji-specific skin tone preferences - return props.emojiSkinToneDefault ?? EmojiSkinTone.None; - }, [props.emojiSkinToneDefault]); + return emojiSkinToneDefault ?? EmojiSkinTone.None; + }, [emojiSkinToneDefault]); const emojiVariant = useMemo(() => { return getEmojiVariantByParentKeyAndSkinTone(emojiParent.key, skinTone); }, [emojiParent, skinTone]); - const handleClick = useCallback( + const handlePress = useCallback(() => { + if (emojiHasSkinToneVariants && emojiSkinToneDefault == null) { + setPopoverOpen(true); + return; + } + + onSelectEmoji({ + variantKey: emojiVariant.key, + parentKey: emojiParent.key, + englishShortName: emojiParent.englishShortNameDefault, + skinTone, + }); + }, [ + emojiHasSkinToneVariants, + emojiSkinToneDefault, + emojiVariant, + emojiParent, + onSelectEmoji, + skinTone, + ]); + + const handleLongPress = useCallback(() => { + if (emojiHasSkinToneVariants) { + setPopoverOpen(true); + } + }, [emojiHasSkinToneVariants]); + + const handleContextMenu = useCallback( (event: MouseEvent) => { - onPressEmoji(event, { + if (emojiHasSkinToneVariants) { + event.stopPropagation(); + event.preventDefault(); + setPopoverOpen(true); + } + }, + [emojiHasSkinToneVariants] + ); + + const handleSelectSkinTone = useCallback( + (skinToneSelection: EmojiSkinTone) => { + onEmojiSkinToneDefaultChange(skinToneSelection); + onSelectEmoji({ variantKey: emojiVariant.key, parentKey: emojiParent.key, englishShortName: emojiParent.englishShortNameDefault, - skinTone, + skinTone: skinToneSelection, }); }, - [emojiVariant, emojiParent, onPressEmoji, skinTone] + [ + onEmojiSkinToneDefaultChange, + emojiVariant.key, + emojiParent.key, + emojiParent.englishShortNameDefault, + onSelectEmoji, + ] ); + const emojiName = emojiLocalizer(emojiVariant.key); + return ( + {emojiHasSkinToneVariants && ( + + + + + + + + + + {i18n( + 'icu:FunPanelEmojis__SkinTonePicker__SelectSkinToneForSelectedEmoji', + { emojiName } + )} + + + + + + )} ); }); -type SectionSkinTonePopoverProps = Readonly<{ +type SectionSkinToneHeaderPopoverProps = Readonly<{ i18n: LocalizerType; open: boolean; onOpenChange: (open: boolean) => void; @@ -567,8 +679,8 @@ type SectionSkinTonePopoverProps = Readonly<{ onSelectSkinTone: (emojiSkinTone: EmojiSkinTone) => void; }>; -function SectionSkinTonePopover( - props: SectionSkinTonePopoverProps +function SectionSkinToneHeaderPopover( + props: SectionSkinToneHeaderPopoverProps ): JSX.Element { const { i18n, onOpenChange, onSelectSkinTone } = props; diff --git a/ts/components/fun/panels/FunPanelGifs.tsx b/ts/components/fun/panels/FunPanelGifs.tsx index e399fbd943..f741aa2c5c 100644 --- a/ts/components/fun/panels/FunPanelGifs.tsx +++ b/ts/components/fun/panels/FunPanelGifs.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Range } from '@tanstack/react-virtual'; import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'; -import type { MouseEvent } from 'react'; import React, { memo, useCallback, @@ -11,6 +10,7 @@ import React, { useRef, useState, } from 'react'; +import type { PressEvent } from 'react-aria'; import { useId, VisuallyHidden } from 'react-aria'; import { LRUCache } from 'lru-cache'; import { FunItemButton } from '../base/FunItem'; @@ -111,12 +111,7 @@ type GifsQuery = Readonly<{ }>; export type FunGifSelection = Readonly<{ - id: string; - title: string; - description: string; - url: string; - width: number; - height: number; + gif: GifType; }>; export type FunPanelGifsProps = Readonly<{ @@ -361,7 +356,7 @@ export function FunPanelGifs({ ); const handlePressGif = useCallback( - (_event: MouseEvent, gifSelection: FunGifSelection) => { + (_event: PressEvent, gifSelection: FunGifSelection) => { onFunSelectGif(gifSelection); onSelectGif(gifSelection); // Should always close, cannot select multiple @@ -546,21 +541,14 @@ const Item = memo(function Item(props: { itemOffset: number; itemLane: number; isTabbable: boolean; - onPressGif: (event: MouseEvent, gifSelection: FunGifSelection) => void; + onPressGif: (event: PressEvent, gifSelection: FunGifSelection) => void; fetchGif: typeof tenorDownload; }) { const { onPressGif, fetchGif } = props; - const handleClick = useCallback( - async (event: MouseEvent) => { - onPressGif(event, { - 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, - }); + const handlePress = useCallback( + async (event: PressEvent) => { + onPressGif(event, { gif: props.gif }); }, [props.gif, onPressGif] ); @@ -617,7 +605,7 @@ const Item = memo(function Item(props: { > {src != null && ( diff --git a/ts/components/fun/panels/FunPanelStickers.tsx b/ts/components/fun/panels/FunPanelStickers.tsx index 4d0e0f1ff7..d2814cbe58 100644 --- a/ts/components/fun/panels/FunPanelStickers.tsx +++ b/ts/components/fun/panels/FunPanelStickers.tsx @@ -1,6 +1,6 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { CSSProperties, MouseEvent } from 'react'; +import type { CSSProperties } from 'react'; import React, { memo, useCallback, @@ -9,6 +9,7 @@ import React, { useRef, useState, } from 'react'; +import type { PressEvent } from 'react-aria'; import type { StickerPackType, StickerType, @@ -339,7 +340,7 @@ export function FunPanelStickers({ }, [searchInput]); const handlePressSticker = useCallback( - (event: MouseEvent, stickerSelection: FunStickerSelection) => { + (event: PressEvent, stickerSelection: FunStickerSelection) => { onFunSelectSticker(stickerSelection); onSelectSticker(stickerSelection); if (!(event.ctrlKey || event.metaKey)) { @@ -351,7 +352,7 @@ export function FunPanelStickers({ ); const handlePressTimeSticker = useCallback( - (event: MouseEvent, style: FunTimeStickerStyle) => { + (event: PressEvent, style: FunTimeStickerStyle) => { onSelectTimeSticker?.(style); if (!(event.ctrlKey || event.metaKey)) { onClose(); @@ -510,10 +511,10 @@ const Row = memo(function Row(props: { cells: ReadonlyArray; focusedCellKey: CellKey | null; onPressSticker: ( - event: MouseEvent, + event: PressEvent, stickerSelection: FunStickerSelection ) => void; - onPressTimeSticker: (event: MouseEvent, style: FunTimeStickerStyle) => void; + onPressTimeSticker: (event: PressEvent, style: FunTimeStickerStyle) => void; }): JSX.Element { return ( @@ -548,16 +549,16 @@ const Cell = memo(function Cell(props: { stickerLookup: StickerLookup; isTabbable: boolean; onPressSticker: ( - event: MouseEvent, + event: PressEvent, stickerSelection: FunStickerSelection ) => void; - onPressTimeSticker: (event: MouseEvent, style: FunTimeStickerStyle) => void; + onPressTimeSticker: (event: PressEvent, style: FunTimeStickerStyle) => void; }): JSX.Element { const { onPressSticker, onPressTimeSticker } = props; const stickerLookupItem = props.stickerLookup[props.value]; - const handleClick = useCallback( - (event: MouseEvent) => { + const handlePress = useCallback( + (event: PressEvent) => { if (stickerLookupItem.kind === 'sticker') { onPressSticker(event, { stickerPackId: stickerLookupItem.sticker.packId, @@ -584,7 +585,7 @@ const Cell = memo(function Cell(props: { ? (stickerLookupItem.sticker.emoji ?? '') : stickerLookupItem.style } - onClick={handleClick} + onPress={handlePress} > {stickerLookupItem.kind === 'sticker' && ( { const parentKey = getEmojiParentKeyByVariantKey(variantKey); const localeShortName = emojiLocalizerIndex.get(parentKey); - strictAssert( - localeShortName, - `useFunEmojiLocalizer: Missing translation for ${variantKey}` - ); - return localeShortName; + if (localeShortName != null) { + return localeShortName; + } + // Fallback to english short name + const parent = getEmojiParentByKey(parentKey); + return parent.englishShortNameDefault; }, [emojiLocalizerIndex] ); diff --git a/ts/services/allLoaders.ts b/ts/services/allLoaders.ts index 19e76d8755..a78cc57474 100644 --- a/ts/services/allLoaders.ts +++ b/ts/services/allLoaders.ts @@ -28,6 +28,7 @@ import { import { type ReduxInitData } from '../state/initializeRedux'; import { reinitializeRedux } from '../state/reinitializeRedux'; +import { getGifsStateForRedux, loadGifsState } from './gifsLoader'; export async function loadAll(): Promise { await Promise.all([ @@ -35,6 +36,7 @@ export async function loadAll(): Promise { loadCallHistory(), loadCallLinks(), loadDistributionLists(), + loadGifsState(), loadRecentEmojis(), loadStickers(), loadStories(), @@ -55,6 +57,7 @@ export function getParametersForRedux(): ReduxInitData { callHistory: getCallsHistoryForRedux(), callHistoryUnreadCount: getCallsHistoryUnreadCountForRedux(), callLinks: getCallLinksForRedux(), + gifs: getGifsStateForRedux(), mainWindowStats, menuOptions, recentEmoji: getEmojiReducerState(), diff --git a/ts/services/gifsLoader.ts b/ts/services/gifsLoader.ts new file mode 100644 index 0000000000..aa405a7ce1 --- /dev/null +++ b/ts/services/gifsLoader.ts @@ -0,0 +1,21 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { DataReader } from '../sql/Client'; +import type { GifsStateType } from '../state/ducks/gifs'; +import { MAX_RECENT_GIFS } from '../state/ducks/gifs'; +import { strictAssert } from '../util/assert'; + +let state: GifsStateType; + +export async function loadGifsState(): Promise { + const recentGifs = await DataReader.getRecentGifs(MAX_RECENT_GIFS); + state = { recentGifs }; +} + +export function getGifsStateForRedux(): GifsStateType { + strictAssert( + state != null, + 'getGifsStateForRedux: state has not been loaded' + ); + return state; +} diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index e1a76ab681..ce960f8e33 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -44,6 +44,7 @@ import type { } from '../types/GroupSendEndorsements'; import type { SyncTaskType } from '../util/syncTasks'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup'; +import type { GifType } from '../components/fun/panels/FunPanelGifs'; export type ReadableDB = Database & { __readable_db: never }; export type WritableDB = ReadableDB & { __writable_db: never }; @@ -706,6 +707,7 @@ type ReadableInterface = { getRecentStickers: (options?: { limit?: number }) => Array; getRecentEmojis: (limit?: number) => Array; + getRecentGifs: (limit: number) => ReadonlyArray; getAllBadges(): Array; @@ -988,6 +990,9 @@ type WritableInterface = { updateEmojiUsage: (shortName: string, timeUsed?: number) => void; + addRecentGif: (gif: GifType, lastUsedAt: number, maxRecents: number) => void; + removeRecentGif: (gif: Pick) => void; + updateOrCreateBadges(badges: ReadonlyArray): void; badgeImageFileDownloaded(url: string, localPath: string): void; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 0971be3940..a00d9b12da 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -211,6 +211,7 @@ import { getGroupSendMemberEndorsement, replaceAllEndorsementsForGroup, } from './server/groupSendEndorsements'; +import type { GifType } from '../components/fun/panels/FunPanelGifs'; type ConversationRow = Readonly<{ json: string; @@ -385,6 +386,7 @@ export const DataReader: ServerReadableInterface = { getAllStickers, getRecentStickers, getRecentEmojis, + getRecentGifs, getAllBadges, getAllBadgeImageFileLocalPaths, @@ -564,6 +566,10 @@ export const DataWriter: ServerWritableInterface = { clearAllErrorStickerPackAttempts, updateEmojiUsage, + + addRecentGif, + removeRecentGif, + updateOrCreateBadges, badgeImageFileDownloaded, @@ -6204,6 +6210,103 @@ function getRecentEmojis(db: ReadableDB, limit = 32): Array { return rows || []; } +const RecentGifsRow = z.object({ + id: z.string(), + title: z.string(), + description: z.string(), + previewMedia_url: z.string(), + previewMedia_width: z.number().int(), + previewMedia_height: z.number().int(), + attachmentMedia_url: z.string(), + attachmentMedia_width: z.number().int(), + attachmentMedia_height: z.number().int(), + lastUsedAt: z.number().int(), +}); + +function getRecentGifs(db: ReadableDB, limit: number): ReadonlyArray { + const [query, params] = sql` + SELECT * FROM recentGifs + ORDER BY lastUsedAt DESC + LIMIT ${limit} + `; + return db + .prepare(query) + .all(params) + .map((raw: unknown) => { + const row = parseUnknown(RecentGifsRow, raw); + return { + id: row.id, + title: row.title, + description: row.description, + previewMedia: { + url: row.previewMedia_url, + width: row.previewMedia_width, + height: row.previewMedia_height, + }, + attachmentMedia: { + url: row.attachmentMedia_url, + width: row.attachmentMedia_width, + height: row.attachmentMedia_height, + }, + }; + }); +} + +function addRecentGif( + db: WritableDB, + gif: GifType, + lastUsedAt: number, + maxRecents: number +): void { + const [insertQuery, insertParams] = sql` + INSERT OR REPLACE INTO recentGifs ( + id, + title, + description, + previewMedia_url, + previewMedia_width, + previewMedia_height, + attachmentMedia_url, + attachmentMedia_width, + attachmentMedia_height, + lastUsedAt + ) VALUES ( + ${gif.id}, + ${gif.title}, + ${gif.description}, + ${gif.previewMedia.url}, + ${gif.previewMedia.width}, + ${gif.previewMedia.height}, + ${gif.attachmentMedia.url}, + ${gif.attachmentMedia.width}, + ${gif.attachmentMedia.height}, + ${lastUsedAt} + ); + `; + + const [deleteQuery, deleteParams] = sql` + DELETE FROM recentGifs + WHERE id NOT IN ( + SELECT id FROM recentGifs + ORDER BY lastUsedAt DESC + LIMIT ${maxRecents} + ); + `; + + db.transaction(() => { + db.prepare(insertQuery).run(insertParams); + db.prepare(deleteQuery).run(deleteParams); + })(); +} + +function removeRecentGif(db: WritableDB, gif: Pick): void { + const [query, params] = sql` + DELETE FROM recentGifs + WHERE id = ${gif.id} + `; + db.prepare(query).run(params); +} + function getAllBadges(db: ReadableDB): Array { return db.transaction(() => { const badgeRows = db.prepare('SELECT * FROM badges').all<{ diff --git a/ts/sql/migrations/1340-recent-gifs.ts b/ts/sql/migrations/1340-recent-gifs.ts new file mode 100644 index 0000000000..4da06d8a69 --- /dev/null +++ b/ts/sql/migrations/1340-recent-gifs.ts @@ -0,0 +1,44 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { Database } from '@signalapp/sqlcipher'; +import type { LoggerType } from '../../types/Logging'; +import { sql } from '../util'; + +export const version = 1340; + +export function updateToSchemaVersion1340( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 1340) { + return; + } + + db.transaction(() => { + const [query] = sql` + CREATE TABLE recentGifs ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + previewMedia_url TEXT NOT NULL, + previewMedia_width INTEGER NOT NULL, + previewMedia_height INTEGER NOT NULL, + attachmentMedia_url TEXT NOT NULL, + attachmentMedia_width INTEGER NOT NULL, + attachmentMedia_height INTEGER NOT NULL, + lastUsedAt INTEGER NOT NULL + ) STRICT; + + CREATE INDEX recentGifs_order ON recentGifs ( + lastUsedAt DESC + ); + `; + + db.exec(query); + + db.pragma('user_version = 1340'); + })(); + + logger.info('updateToSchemaVersion1340: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 10a5e2e2f7..5d86cb6632 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -108,10 +108,11 @@ import { updateToSchemaVersion1290 } from './1290-int-unprocessed-source-device' import { updateToSchemaVersion1300 } from './1300-sticker-pack-refs'; import { updateToSchemaVersion1310 } from './1310-muted-fixup'; import { updateToSchemaVersion1320 } from './1320-unprocessed-received-at-date'; +import { updateToSchemaVersion1330 } from './1330-sync-tasks-type-index'; import { - updateToSchemaVersion1330, + updateToSchemaVersion1340, version as MAX_VERSION, -} from './1330-sync-tasks-type-index'; +} from './1340-recent-gifs'; import { DataWriter } from '../Server'; @@ -2098,6 +2099,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion1310, updateToSchemaVersion1320, updateToSchemaVersion1330, + updateToSchemaVersion1340, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/state/actions.ts b/ts/state/actions.ts index eb6fde5e47..fa14fde171 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -13,6 +13,7 @@ import { actions as conversations } from './ducks/conversations'; import { actions as crashReports } from './ducks/crashReports'; import { actions as emojis } from './ducks/emojis'; import { actions as expiration } from './ducks/expiration'; +import { actions as gifs } from './ducks/gifs'; import { actions as globalModals } from './ducks/globalModals'; import { actions as inbox } from './ducks/inbox'; import { actions as installer } from './ducks/installer'; @@ -45,6 +46,7 @@ export const actionCreators: ReduxActions = { crashReports, emojis, expiration, + gifs, globalModals, inbox, installer, diff --git a/ts/state/ducks/gifs.ts b/ts/state/ducks/gifs.ts new file mode 100644 index 0000000000..88c8138082 --- /dev/null +++ b/ts/state/ducks/gifs.ts @@ -0,0 +1,113 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { ReadonlyDeep } from 'type-fest'; +import type { ThunkAction } from 'redux-thunk'; +import { take } from 'lodash'; +import type { GifType } from '../../components/fun/panels/FunPanelGifs'; +import { DataWriter } from '../../sql/Client'; +import { + type BoundActionCreatorsMapObject, + useBoundActions, +} from '../../hooks/useBoundActions'; + +const { addRecentGif, removeRecentGif } = DataWriter; + +export const MAX_RECENT_GIFS = 64; + +type RecentGifs = ReadonlyDeep>; + +// State + +export type GifsStateType = ReadonlyDeep<{ + recentGifs: RecentGifs; +}>; + +// Actions + +const GIFS_RECENT_GIFS_ADD = 'gifs/RECENT_GIFS_ADD'; +const GIFS_RECENT_GIFS_REMOVE = 'gifs/RECENT_GIFS_REMOVE'; + +export type GifsRecentGifsAdd = ReadonlyDeep<{ + type: typeof GIFS_RECENT_GIFS_ADD; + payload: GifType; +}>; + +export type GifsRecentGifsRemove = ReadonlyDeep<{ + type: typeof GIFS_RECENT_GIFS_REMOVE; + payload: Pick; +}>; + +type GifsActionType = ReadonlyDeep; + +// Action Creators + +export const actions = { + onAddRecentGif, + onRemoveRecentGif, +}; + +export const useGifsActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); + +function onAddRecentGif( + payload: GifType +): ThunkAction { + return async dispatch => { + await addRecentGif(payload, Date.now(), MAX_RECENT_GIFS); + dispatch({ type: GIFS_RECENT_GIFS_ADD, payload }); + }; +} + +function onRemoveRecentGif( + payload: Pick +): ThunkAction { + return async dispatch => { + await removeRecentGif(payload); + dispatch({ type: GIFS_RECENT_GIFS_REMOVE, payload }); + }; +} + +function filterRecentGif( + prev: ReadonlyArray, + gifId: GifType['id'] +): ReadonlyArray { + return prev.filter(gif => gif.id !== gifId); +} + +function addOrMoveRecentGifToStart(prev: RecentGifs, gif: GifType): RecentGifs { + // Make sure there isn't a duplicate item in the array + const filtered = filterRecentGif(prev, gif.id); + // Make sure final array isn't too long + const limited = take(filtered, MAX_RECENT_GIFS - 1); + return [gif, ...limited]; +} + +// Reducer + +export function getEmptyState(): GifsStateType { + return { recentGifs: [] }; +} + +export function reducer( + state: GifsStateType = getEmptyState(), + action: GifsActionType +): GifsStateType { + if (action.type === GIFS_RECENT_GIFS_ADD) { + const { payload } = action; + return { + ...state, + recentGifs: addOrMoveRecentGifToStart(state.recentGifs, payload), + }; + } + + if (action.type === GIFS_RECENT_GIFS_REMOVE) { + const { payload } = action; + return { + ...state, + recentGifs: filterRecentGif(state.recentGifs, payload.id), + }; + } + + return state; +} diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index 8631122985..3a87390d5b 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -13,6 +13,7 @@ import { getEmptyState as conversationsEmptyState } from './ducks/conversations' import { getEmptyState as crashReportsEmptyState } from './ducks/crashReports'; import { getEmptyState as emojiEmptyState } from './ducks/emojis'; import { getEmptyState as expirationEmptyState } from './ducks/expiration'; +import { getEmptyState as gifsEmptyState } from './ducks/gifs'; import { getEmptyState as globalModalsEmptyState } from './ducks/globalModals'; import { getEmptyState as inboxEmptyState } from './ducks/inbox'; import { getEmptyState as installerEmptyState } from './ducks/installer'; @@ -55,6 +56,7 @@ export function getInitialState( callLinks, callHistory: calls, callHistoryUnreadCount, + gifs, mainWindowStats, menuOptions, recentEmoji, @@ -82,6 +84,7 @@ export function getInitialState( callLinks: makeLookup(callLinks, 'roomId'), }, emojis: recentEmoji, + gifs, items, stickers, stories: { @@ -131,6 +134,7 @@ function getEmptyState(): StateType { conversations: generateConversationsState(), crashReports: crashReportsEmptyState(), emojis: emojiEmptyState(), + gifs: gifsEmptyState(), expiration: expirationEmptyState(), globalModals: globalModalsEmptyState(), inbox: inboxEmptyState(), diff --git a/ts/state/initializeRedux.ts b/ts/state/initializeRedux.ts index 2f8b8d9643..4ec61558e5 100644 --- a/ts/state/initializeRedux.ts +++ b/ts/state/initializeRedux.ts @@ -16,12 +16,14 @@ import type { ThemeType } from '../types/Util'; import type { CallLinkType } from '../types/CallLink'; import type { RecentEmojiObjectType } from '../util/loadRecentEmojis'; import type { StickersStateType } from './ducks/stickers'; +import type { GifsStateType } from './ducks/gifs'; export type ReduxInitData = { badgesState: BadgesStateType; callHistory: ReadonlyArray; callHistoryUnreadCount: number; callLinks: ReadonlyArray; + gifs: GifsStateType; mainWindowStats: MainWindowStatsType; menuOptions: MenuOptionsType; recentEmoji: RecentEmojiObjectType; @@ -63,6 +65,7 @@ export function initializeRedux(data: ReduxInitData): void { inbox: bindActionCreators(actionCreators.inbox, store.dispatch), emojis: bindActionCreators(actionCreators.emojis, store.dispatch), expiration: bindActionCreators(actionCreators.expiration, store.dispatch), + gifs: bindActionCreators(actionCreators.gifs, store.dispatch), globalModals: bindActionCreators( actionCreators.globalModals, store.dispatch diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 56be010ab0..98cde5c9a7 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -15,6 +15,7 @@ import { reducer as conversations } from './ducks/conversations'; import { reducer as crashReports } from './ducks/crashReports'; import { reducer as emojis } from './ducks/emojis'; import { reducer as expiration } from './ducks/expiration'; +import { reducer as gifs } from './ducks/gifs'; import { reducer as globalModals } from './ducks/globalModals'; import { reducer as inbox } from './ducks/inbox'; import { reducer as installer } from './ducks/installer'; @@ -48,6 +49,7 @@ export const reducer = combineReducers({ crashReports, emojis, expiration, + gifs, globalModals, inbox, installer, diff --git a/ts/state/selectors/gifs.ts b/ts/state/selectors/gifs.ts new file mode 100644 index 0000000000..3344ca7ae3 --- /dev/null +++ b/ts/state/selectors/gifs.ts @@ -0,0 +1,11 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { createSelector } from 'reselect'; +import type { StateType } from '../reducer'; +import type { GifsStateType } from '../ducks/gifs'; + +export const selectGifs = (state: StateType): GifsStateType => state.gifs; + +export const getRecentGifs = createSelector(selectGifs, gifs => { + return gifs.recentGifs; +}); diff --git a/ts/state/smart/DraftGifMessageSendModal.tsx b/ts/state/smart/DraftGifMessageSendModal.tsx index 82d894a511..67b4d0dd61 100644 --- a/ts/state/smart/DraftGifMessageSendModal.tsx +++ b/ts/state/smart/DraftGifMessageSendModal.tsx @@ -92,7 +92,7 @@ export const SmartDraftGifMessageSendModal = memo( toggleDraftGifMessageSendModal(null); }, [toggleDraftGifMessageSendModal]); - const gifUrl = gifSelection.url; + const gifUrl = gifSelection.gif.attachmentMedia.url; useEffect(() => { const controller = new AbortController(); diff --git a/ts/state/smart/FunProvider.tsx b/ts/state/smart/FunProvider.tsx index d592f9e734..153399940c 100644 --- a/ts/state/smart/FunProvider.tsx +++ b/ts/state/smart/FunProvider.tsx @@ -7,10 +7,7 @@ import { useSelector } from 'react-redux'; import { FunProvider } from '../../components/fun/FunProvider'; import { getIntl } from '../selectors/user'; import { selectRecentEmojis } from '../selectors/emojis'; -import type { - FunGifSelection, - GifType, -} from '../../components/fun/panels/FunPanelGifs'; +import type { FunGifSelection } from '../../components/fun/panels/FunPanelGifs'; import { getInstalledStickerPacks, getRecentStickers, @@ -26,6 +23,7 @@ import { getShowStickerPickerHint, } from '../selectors/items'; import { useItemsActions } from '../ducks/items'; +import { useGifsActions } from '../ducks/gifs'; import { fetchGifsFeatured, fetchGifsSearch, @@ -36,6 +34,7 @@ import { useEmojisActions } from '../ducks/emojis'; import { useStickersActions } from '../ducks/stickers'; import type { FunStickerSelection } from '../../components/fun/panels/FunPanelStickers'; import type { FunEmojiSelection } from '../../components/fun/panels/FunPanelEmojis'; +import { getRecentGifs } from '../selectors/gifs'; export type SmartFunProviderProps = Readonly<{ children: ReactNode; @@ -48,7 +47,7 @@ export const SmartFunProvider = memo(function SmartFunProvider( const installedStickerPacks = useSelector(getInstalledStickerPacks); const recentEmojis = useSelector(selectRecentEmojis); const recentStickers = useSelector(getRecentStickers); - const recentGifs: Array = useMemo(() => [], []); + const recentGifs = useSelector(getRecentGifs); const emojiSkinToneDefault = useSelector(getEmojiSkinToneDefault); const showStickerPickerHint = useSelector(getShowStickerPickerHint); @@ -57,6 +56,7 @@ export const SmartFunProvider = memo(function SmartFunProvider( usePreferredReactionsActions(); const { onUseEmoji } = useEmojisActions(); const { useSticker: onUseSticker } = useStickersActions(); + const { onAddRecentGif } = useGifsActions(); // Translate recent emojis to keys const recentEmojisKeys = useMemo(() => { @@ -104,9 +104,12 @@ export const SmartFunProvider = memo(function SmartFunProvider( ); // GIFs - const handleSelectGif = useCallback((_gifSelection: FunGifSelection) => { - // TODO(jamie): Save recently used GIFs - }, []); + const handleSelectGif = useCallback( + (gifSelection: FunGifSelection) => { + onAddRecentGif(gifSelection.gif); + }, + [onAddRecentGif] + ); return ( (null);", + "reasonCategory": "usageTrusted", + "updated": "2025-04-10T18:24:54.606Z" + }, { "rule": "React-useRef", "path": "ts/components/fun/panels/FunPanelGifs.tsx", diff --git a/ts/windows/main/preload_test.ts b/ts/windows/main/preload_test.ts index b068f4135a..b3e4127743 100644 --- a/ts/windows/main/preload_test.ts +++ b/ts/windows/main/preload_test.ts @@ -104,6 +104,9 @@ window.testUtilities = { callLinks: [], callHistory: [], callHistoryUnreadCount: 0, + gifs: { + recentGifs: [], + }, mainWindowStats: { isFullScreen: false, isMaximized: false,