diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index c7a61ae674..248a866c6f 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -250,6 +250,7 @@ function withFunProvider(Story, context) { recentGifs={[]} emojiSkinToneDefault={Emoji.SkinTone.None} onEmojiSkinToneDefaultChange={noop} + isStickerReplySendEnabled installedStickerPacks={[]} showStickerPickerHint={false} onClearStickerPickerHint={noop} diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 975ecda903..96b02cb98a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4086,6 +4086,22 @@ "messageformat": "Add a Sticker", "description": "Sticker Picker > Trigger Button > Accessibility label" }, + "icu:FunPicker__Label--StickerReply": { + "messageformat": "Reply", + "description": "When replying to a message and previewing a sticker to send, this is the label for the preview view." + }, + "icu:FunPicker__Label--SendStickerReply": { + "messageformat": "Send", + "description": "When replying to a message and previewing a sticker to send, this is the accessibility label for the Send button. Sticker Picker > Preview sticker > Send button > Accessibility label" + }, + "icu:FunPicker__Label--CancelStickerReply": { + "messageformat": "Cancel", + "description": "When replying to a message and previewing a sticker to send, this is the accessibility label for the Cancel button. Sticker Picker > Preview sticker > Cancel button > Accessibility label" + }, + "icu:FunPicker__Label--Sticker": { + "messageformat": "Sticker", + "description": "When replying to a message and previewing a sticker to send, this is the accessibility label for the sticker preview image." + }, "icu:confirmation-dialog--Cancel": { "messageformat": "Cancel", "description": "Appears on the cancel button in confirmation dialogs." diff --git a/ts/components/CompositionArea.dom.tsx b/ts/components/CompositionArea.dom.tsx index 08b4ff070e..9e51daaf14 100644 --- a/ts/components/CompositionArea.dom.tsx +++ b/ts/components/CompositionArea.dom.tsx @@ -793,6 +793,7 @@ export const CompositionArea = memo(function CompositionArea({ )} > null} onSelectEmoji={action('onSelectEmoji')} // Stickers + isStickerReplySendEnabled installedStickerPacks={packs} showStickerPickerHint={false} onClearStickerPickerHint={() => null} diff --git a/ts/components/fun/FunPicker.dom.stories.tsx b/ts/components/fun/FunPicker.dom.stories.tsx index 6b4eb7ffa9..acd14c3e83 100644 --- a/ts/components/fun/FunPicker.dom.stories.tsx +++ b/ts/components/fun/FunPicker.dom.stories.tsx @@ -38,6 +38,7 @@ function Template(props: TemplateProps) { onOpenCustomizePreferredReactionsModal={() => null} onSelectEmoji={() => null} // Stickers + isStickerReplySendEnabled installedStickerPacks={packs} showStickerPickerHint={false} onClearStickerPickerHint={() => null} @@ -60,6 +61,7 @@ export default { title: 'Components/Fun/FunPicker', component: Template, args: { + isReply: false, placement: 'bottom', theme: undefined, onSelectEmoji: action('onSelectEmoji'), diff --git a/ts/components/fun/FunPicker.dom.tsx b/ts/components/fun/FunPicker.dom.tsx index d04b271d60..ea36bcb43c 100644 --- a/ts/components/fun/FunPicker.dom.tsx +++ b/ts/components/fun/FunPicker.dom.tsx @@ -1,8 +1,9 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { Dialog } from 'radix-ui'; import type { ReactNode, JSX } from 'react'; import { memo, useCallback, useEffect } from 'react'; -import type { Placement } from 'react-aria'; +import { type Placement, VisuallyHidden } from 'react-aria'; import { DialogTrigger } from 'react-aria-components'; import { createKeybindingsHandler } from 'tinykeys'; import { FunPickerTabKey } from './constants.dom.tsx'; @@ -22,6 +23,12 @@ import { FunPanelStickers } from './panels/FunPanelStickers.dom.tsx'; import { useFunContext } from './FunProvider.dom.tsx'; import type { ThemeType } from '../../types/Util.std.ts'; import { FunErrorBoundary } from './base/FunErrorBoundary.dom.tsx'; +import { strictAssert } from '../../util/assert.std.ts'; +import { FunSticker } from './FunSticker.dom.tsx'; +import { AxoIconButton } from '../../axo/AxoIconButton.dom.tsx'; +import { AxoSymbol } from '../../axo/AxoSymbol.dom.tsx'; +import type { LocalizerType } from '../../types/I18N.std.ts'; +import { tw } from '../../axo/tw.dom.tsx'; /** * FunPicker @@ -29,6 +36,7 @@ import { FunErrorBoundary } from './base/FunErrorBoundary.dom.tsx'; export type FunPickerProps = Readonly<{ open: boolean; + isReply: boolean; onOpenChange: (open: boolean) => void; onSelectEmoji: (emojiSelection: FunEmojiSelection) => void; onSelectSticker: (stickerSelection: FunStickerSelection) => void; @@ -42,9 +50,19 @@ export type FunPickerProps = Readonly<{ export const FunPicker = memo(function FunPicker( props: FunPickerProps ): JSX.Element { - const { onOpenChange } = props; + const { isReply, onOpenChange, onSelectSticker } = props; const fun = useFunContext(); - const { i18n, onOpenChange: onFunOpenChange, onChangeTab } = fun; + const { + i18n, + isStickerReplySendEnabled, + stagedStickerReply, + onOpenChange: onFunOpenChange, + onSelectSticker: onFunSelectSticker, + onChangeTab, + onStageStickerReply, + } = fun; + + const isReplyForFunPanel = isStickerReplySendEnabled && isReply; const handleOpenChange = useCallback( (open: boolean) => { @@ -58,6 +76,18 @@ export const FunPicker = memo(function FunPicker( handleOpenChange(false); }, [handleOpenChange]); + const handleCancelStickerReply = useCallback(() => { + onStageStickerReply(null); + }, [onStageStickerReply]); + + const handleSendStickerReply = useCallback(() => { + strictAssert(stagedStickerReply, 'Staged sticker reply is required'); + + onFunSelectSticker(stagedStickerReply); + onSelectSticker(stagedStickerReply); + handleClose(); + }, [stagedStickerReply, handleClose, onFunSelectSticker, onSelectSticker]); + useEffect(() => { const onKeyDown = createKeybindingsHandler({ '$mod+Shift+J': () => { @@ -84,6 +114,12 @@ export const FunPicker = memo(function FunPicker( {props.children} + {i18n('icu:FunPicker__Tab--Emojis')} @@ -108,6 +144,7 @@ export const FunPicker = memo(function FunPicker( ); }); + +const StagedStickerReply = memo(function StagedStickerReply(props: { + i18n: LocalizerType; + selection: FunStickerSelection | null; + handleCancelStickerReply: () => void; + handleSendStickerReply: () => void; +}): JSX.Element | null { + const { i18n, selection, handleCancelStickerReply, handleSendStickerReply } = + props; + if (!selection) { + return null; + } + + return ( + + event.stopPropagation()}> +
+
+ +
+
+ + + +   + {i18n('icu:FunPicker__Label--StickerReply')} + + +
+
+ + + {i18n('icu:FunPicker__Label--StickerReply')} + + +
+
+ ); +}); diff --git a/ts/components/fun/FunProvider.dom.tsx b/ts/components/fun/FunProvider.dom.tsx index 6a33470907..6bf6253b0c 100644 --- a/ts/components/fun/FunProvider.dom.tsx +++ b/ts/components/fun/FunProvider.dom.tsx @@ -41,6 +41,7 @@ export type FunContextSmartProps = Readonly<{ onSelectEmoji: (emojiSelection: FunEmojiSelection) => void; // Stickers + isStickerReplySendEnabled: boolean; installedStickerPacks: ReadonlyArray; showStickerPickerHint: boolean; onClearStickerPickerHint: () => unknown; @@ -68,6 +69,10 @@ export type FunContextProps = FunContextSmartProps & onStoredSearchInputChange: (nextSearchInput: string) => void; shouldAutoFocus: boolean; onChangeShouldAutoFocus: (shouldAutoFocus: boolean) => void; + + // Sticker reply + stagedStickerReply: FunStickerSelection | null; + onStageStickerReply: (selection: FunStickerSelection | null) => void; }>; const FunContext = createContext(null); @@ -112,6 +117,16 @@ export const FunProvider = memo(function FunProvider( [] ); + // Sticker reply + const [stagedStickerReply, setStagedStickerReply] = + useState(null); + const handleStageStickerReply = useCallback( + (selection: FunStickerSelection | null) => { + setStagedStickerReply(selection); + }, + [] + ); + const [shouldAutoFocus, setShouldAutoFocus] = useState(true); const handleChangeShouldAutofocus = useCallback( (nextShouldAutoFocus: boolean) => { @@ -153,10 +168,13 @@ export const FunProvider = memo(function FunProvider( } onSelectEmoji={props.onSelectEmoji} // Stickers + isStickerReplySendEnabled={props.isStickerReplySendEnabled} installedStickerPacks={props.installedStickerPacks} showStickerPickerHint={props.showStickerPickerHint} + stagedStickerReply={stagedStickerReply} onClearStickerPickerHint={props.onClearStickerPickerHint} onSelectSticker={props.onSelectSticker} + onStageStickerReply={handleStageStickerReply} // GIFs fetchGiphyTrending={props.fetchGiphyTrending} fetchGiphySearch={props.fetchGiphySearch} diff --git a/ts/components/fun/FunStickerPicker.dom.stories.tsx b/ts/components/fun/FunStickerPicker.dom.stories.tsx index f176113f2d..9e3e248481 100644 --- a/ts/components/fun/FunStickerPicker.dom.stories.tsx +++ b/ts/components/fun/FunStickerPicker.dom.stories.tsx @@ -38,6 +38,7 @@ function Template(props: TemplateProps): JSX.Element { onOpenCustomizePreferredReactionsModal={() => null} onSelectEmoji={() => null} // Stickers + isStickerReplySendEnabled installedStickerPacks={packs} showStickerPickerHint={false} onClearStickerPickerHint={() => null} diff --git a/ts/components/fun/FunStickerPicker.dom.tsx b/ts/components/fun/FunStickerPicker.dom.tsx index 9002e26f39..2f4cb7af43 100644 --- a/ts/components/fun/FunStickerPicker.dom.tsx +++ b/ts/components/fun/FunStickerPicker.dom.tsx @@ -48,6 +48,7 @@ export const FunStickerPicker = memo(function FunStickerPicker( ; export type FunPanelStickersProps = Readonly<{ + isReply: boolean; showTimeStickers: boolean; onSelectTimeSticker?: (style: FunTimeStickerStyle) => void; onSelectSticker: (stickerSelection: FunStickerSelection) => void; @@ -182,6 +183,7 @@ export type FunPanelStickersProps = Readonly<{ }>; export function FunPanelStickers({ + isReply, showTimeStickers, onSelectTimeSticker, onSelectSticker, @@ -196,6 +198,7 @@ export function FunPanelStickers({ recentStickers, installedStickerPacks, onSelectSticker: onFunSelectSticker, + onStageStickerReply, } = fun; const scrollerRef = useRef(null); @@ -358,6 +361,11 @@ export function FunPanelStickers({ const handleClickSticker = useCallback( (event: PointerEvent, stickerSelection: FunStickerSelection) => { + if (isReply) { + onStageStickerReply(stickerSelection); + return; + } + onFunSelectSticker(stickerSelection); onSelectSticker(stickerSelection); if (!(event.ctrlKey || event.metaKey)) { @@ -365,7 +373,7 @@ export function FunPanelStickers({ onClose(); } }, - [onFunSelectSticker, onSelectSticker, onClose] + [isReply, onFunSelectSticker, onSelectSticker, onStageStickerReply, onClose] ); const handleClickTimeSticker = useCallback( diff --git a/ts/state/smart/FunProvider.preload.tsx b/ts/state/smart/FunProvider.preload.tsx index 71da7f122d..d00284c197 100644 --- a/ts/state/smart/FunProvider.preload.tsx +++ b/ts/state/smart/FunProvider.preload.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from 'react'; import { memo, useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { FunProvider } from '../../components/fun/FunProvider.dom.tsx'; -import { getIntl } from '../selectors/user.std.ts'; +import { getIntl, getVersion } from '../selectors/user.std.ts'; import { selectRecentEmojis } from '../selectors/emojis.std.ts'; import type { FunGifSelection } from '../../components/fun/panels/FunPanelGifs.dom.tsx'; import { @@ -15,6 +15,7 @@ import { import { strictAssert } from '../../util/assert.std.ts'; import { getEmojiSkinToneDefault, + getItems, getShowStickerPickerHint, } from '../selectors/items.dom.ts'; import { useItemsActions } from '../ducks/items.preload.ts'; @@ -31,6 +32,7 @@ import type { FunStickerSelection } from '../../components/fun/panels/FunPanelSt import type { FunEmojiSelection } from '../../components/fun/panels/FunPanelEmojis.dom.tsx'; import { getRecentGifs } from '../selectors/gifs.std.ts'; import { Emoji } from '../../axo/emoji.std.ts'; +import { isFeaturedEnabledSelector } from '../../util/isFeatureEnabled.dom.ts'; export type SmartFunProviderProps = Readonly<{ children: ReactNode; @@ -54,6 +56,16 @@ export const SmartFunProvider = memo(function SmartFunProvider( const { useSticker: onUseSticker } = useStickersActions(); const { onAddRecentGif, onRemoveRecentGif } = useGifsActions(); + const items = useSelector(getItems); + const version = useSelector(getVersion); + + const isStickerReplySendEnabled = isFeaturedEnabledSelector({ + betaKey: 'desktop.stickerReply.send.beta', + prodKey: 'desktop.stickerReply.send.prod', + currentVersion: version, + remoteConfig: items.remoteConfig, + }); + // Translate recent emojis to keys const recentEmojisKeys = useMemo(() => { return recentEmojis.map(emoji => { @@ -116,6 +128,7 @@ export const SmartFunProvider = memo(function SmartFunProvider( } onSelectEmoji={handleSelectEmoji} // Stickers + isStickerReplySendEnabled={isStickerReplySendEnabled} installedStickerPacks={installedStickerPacks} showStickerPickerHint={showStickerPickerHint} onClearStickerPickerHint={handleClearStickerPickerHint} diff --git a/ts/test-node/processDataMessage_test.preload.ts b/ts/test-node/processDataMessage_test.preload.ts index c546fded62..62ee8188d7 100644 --- a/ts/test-node/processDataMessage_test.preload.ts +++ b/ts/test-node/processDataMessage_test.preload.ts @@ -126,6 +126,25 @@ describe('processDataMessage', () => { ]); }); + it('should process attachments with null fileName', () => { + const out = check({ + attachments: [ + { + ...UNPROCESSED_ATTACHMENT, + fileName: null, + }, + ], + }); + + assert.deepStrictEqual(out.attachments, [ + { + ...PROCESSED_ATTACHMENT, + fileName: undefined, + downloadPath: 'random-path', + }, + ]); + }); + it('should process attachments with 0 cdnId', () => { const out = check({ attachments: [ diff --git a/ts/textsecure/processDataMessage.preload.ts b/ts/textsecure/processDataMessage.preload.ts index a0eb532213..7b436b66c1 100644 --- a/ts/textsecure/processDataMessage.preload.ts +++ b/ts/textsecure/processDataMessage.preload.ts @@ -116,7 +116,7 @@ export function processAttachment( cdnKey: attachmentIdentifier?.cdnKey, cdnNumber: cdnNumber ?? 0, chunkSize: chunkSize ?? 0, - fileName: fileName ?? '', + fileName: fileName ?? undefined, flags: flags ?? 0, width: width ?? 0, height: height ?? 0, diff --git a/ts/util/uploadAttachment.preload.ts b/ts/util/uploadAttachment.preload.ts index f2f55524e7..93dc35d614 100644 --- a/ts/util/uploadAttachment.preload.ts +++ b/ts/util/uploadAttachment.preload.ts @@ -101,7 +101,7 @@ export async function uploadAttachment( let { fileName } = attachment; if (isImageAttachment(attachment) || isVideoAttachment(attachment)) { assertDev( - fileName == null, + fileName == null || fileName === '', 'Filename should be stripped from visual attachments' );