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 && (
+
+
+
+
+
+
+ )}
);
});
-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,