From f28456c160a138252b02d7ed8eb7a88b57833cdb Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Thu, 9 Sep 2021 11:29:01 -0500 Subject: [PATCH] Let users customize the preferred reaction palette --- _locales/en/messages.json | 18 +- stylesheets/_modules.scss | 52 ++- .../CustomizingPreferredReactionsModal.scss | 26 ++ stylesheets/components/ReactionPicker.scss | 162 +++++-- stylesheets/manifest.scss | 1 + ts/background.ts | 2 + ts/components/App.tsx | 6 + ...omizingPreferredReactionsModal.stories.tsx | 71 +++ .../CustomizingPreferredReactionsModal.tsx | 193 ++++++++ ts/components/Inbox.tsx | 13 +- .../conversation/ReactionPicker.stories.tsx | 32 +- ts/components/conversation/ReactionPicker.tsx | 76 +++- ts/components/emoji/EmojiPicker.stories.tsx | 15 +- ts/components/emoji/EmojiPicker.tsx | 50 ++- ts/reactions/constants.ts | 11 + ts/reactions/getPreferredReactionEmoji.ts | 20 + ts/state/ducks/items.ts | 2 + ts/state/ducks/preferredReactions.ts | 315 +++++++++++++ ts/state/reducer.ts | 4 +- ts/state/selectors/items.ts | 19 + ts/state/selectors/preferredReactions.ts | 22 + ts/state/smart/App.tsx | 6 + ts/state/smart/CompositionArea.tsx | 3 +- .../CustomizingPreferredReactionsModal.tsx | 46 ++ ts/state/smart/EmojiPicker.tsx | 14 +- ts/state/smart/ForwardMessageModal.tsx | 4 +- ts/state/smart/ProfileEditorModal.ts | 4 +- ts/state/smart/ReactionPicker.tsx | 40 +- ts/state/smart/renderEmojiPicker.tsx | 2 + .../getPreferredReactionEmoji_test.ts | 46 ++ .../state/ducks/preferredReactions_test.ts | 424 ++++++++++++++++++ ts/test-both/state/selectors/items_test.ts | 102 ++++- .../selectors/preferredReactions_test.ts | 56 +++ ts/test-both/util/replaceIndex_test.ts | 32 ++ ts/types/Storage.d.ts | 1 + ts/types/StorageUIKeys.ts | 1 + ts/util/replaceIndex.ts | 16 + ts/views/conversation_view.ts | 5 +- 38 files changed, 1788 insertions(+), 124 deletions(-) create mode 100644 stylesheets/components/CustomizingPreferredReactionsModal.scss create mode 100644 ts/components/CustomizingPreferredReactionsModal.stories.tsx create mode 100644 ts/components/CustomizingPreferredReactionsModal.tsx create mode 100644 ts/reactions/constants.ts create mode 100644 ts/reactions/getPreferredReactionEmoji.ts create mode 100644 ts/state/ducks/preferredReactions.ts create mode 100644 ts/state/selectors/preferredReactions.ts create mode 100644 ts/state/smart/CustomizingPreferredReactionsModal.tsx create mode 100644 ts/test-both/reactions/getPreferredReactionEmoji_test.ts create mode 100644 ts/test-both/state/ducks/preferredReactions_test.ts create mode 100644 ts/test-both/state/selectors/preferredReactions_test.ts create mode 100644 ts/test-both/util/replaceIndex_test.ts create mode 100644 ts/util/replaceIndex.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e5acee4aa..a8c8e31bb 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1244,7 +1244,11 @@ }, "save": { "message": "Save", - "description": "Used as a 'commit changes' button in the Caption Editor for outgoing image attachments" + "description": "Used on save buttons" + }, + "reset": { + "message": "Reset", + "description": "Used on reset buttons" }, "fileIconAlt": { "message": "File icon", @@ -6301,6 +6305,18 @@ } } }, + "CustomizingPreferredReactions__title": { + "message": "Customize default reactions", + "description": "Shown in the header of the modal for customizing the preferred reactions. Also shown in the tooltip for the button that opens this modal." + }, + "CustomizingPreferredReactions__subtitle": { + "message": "Click to replace an emoji", + "description": "Instructions in the modal for customizing the preferred reactions." + }, + "CustomizingPreferredReactions__had-save-error": { + "message": "There was an error when saving your settings. Please try again.", + "description": "Shown if there is an error when saving your preferred reaction settings. Should be very rare to see this message." + }, "WhatsNew__modal-title": { "message": "What's New", "description": "Title for the whats new modal" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 851ae2bfc..d9388cd8f 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -7439,6 +7439,19 @@ button.module-image__border-overlay:focus { &__footer { @extend %module-emoji-picker--ribbon; justify-content: center; + + &__skin-tones { + align-items: center; + display: flex; + flex-direction: row; + flex-grow: 1; + justify-content: center; + } + + &__settings-spacer { + width: 28px; + margin-right: 12px; + } } &__button { @@ -7457,8 +7470,43 @@ button.module-image__border-overlay:focus { } &--footer { - &:not(:first-of-type) { - margin-left: 4px; + &:not(:last-of-type) { + margin-right: 4px; + } + } + + &--settings { + margin-left: 12px; + border-radius: 100%; + + @include light-theme { + background: $color-white; + box-shadow: 0px 0px 4px $color-black-alpha-20; + } + + @include dark-theme { + background: $color-gray-65; + } + + &::before { + display: block; + width: 20px; + height: 20px; + content: ''; + + @include light-theme { + @include color-svg( + '../images/icons/v2/settings-outline-16.svg', + $color-gray-75 + ); + } + + @include dark-theme { + @include color-svg( + '../images/icons/v2/settings-solid-16.svg', + $color-gray-25 + ); + } } } diff --git a/stylesheets/components/CustomizingPreferredReactionsModal.scss b/stylesheets/components/CustomizingPreferredReactionsModal.scss new file mode 100644 index 000000000..f33e5a74a --- /dev/null +++ b/stylesheets/components/CustomizingPreferredReactionsModal.scss @@ -0,0 +1,26 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-CustomizingPreferredReactionsModal { + &__reaction-picker-wrapper { + @include font-subtitle; + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + padding: 4rem 0; + text-align: center; + user-select: none; + + @include light-theme { + color: $color-gray-45; + } + @include dark-theme { + color: $color-gray-25; + } + + .module-ReactionPicker { + margin-bottom: 2rem; + } + } +} diff --git a/stylesheets/components/ReactionPicker.scss b/stylesheets/components/ReactionPicker.scss index a63ff6a97..33e0179d2 100644 --- a/stylesheets/components/ReactionPicker.scss +++ b/stylesheets/components/ReactionPicker.scss @@ -9,6 +9,8 @@ $emoji-size-from-component: 48px; $big-emoji-size: 42px; + $root-selector: &; + @include rounded-corners; align-items: center; border-style: solid; @@ -19,7 +21,6 @@ padding: 3px 7px; position: relative; user-select: none; - z-index: 2; @media (prefers-reduced-motion: no-preference) { animation: { @@ -48,31 +49,14 @@ justify-content: center; position: relative; - @media (prefers-reduced-motion: no-preference) { - // Prevent animation jank - opacity: 0; - - animation: { - name: module-ReactionPicker__button-appear; - duration: 400ms; - timing-function: $ease-out-expo; - fill-mode: forwards; - // This delay is a fallback in case there are more than the expected number of - // buttons. - delay: #{$max-expected-buttons * 10ms}; - } - } - @for $i from 0 through $max-expected-buttons { - &:nth-of-type(#{$i + 1}) { - animation-delay: #{$i * 10ms}; - } - } - &--emoji { $emoji-button-selector: &; height: $button-size; width: $button-size; + @media (prefers-reduced-motion: no-preference) { + transition: background 200ms $ease-out-expo; + } .module-emoji { transform: scale($button-content-size / $emoji-size-from-component); @@ -80,25 +64,6 @@ transition: transform 400ms $ease-out-expo; } } - - @mixin focused-emoji { - @media (prefers-reduced-motion: no-preference) { - .module-emoji { - transform: scale($big-emoji-size / $emoji-size-from-component) - translateY(-16px); - } - } - } - - &:hover { - @include focused-emoji; - } - - @include keyboard-mode { - &:focus { - @include focused-emoji; - } - } } &--more { @@ -117,6 +82,11 @@ &:hover { background: $color-gray-05; } + @include keyboard-mode { + &:focus { + background: $color-gray-05; + } + } } @include dark-theme { @@ -125,6 +95,11 @@ &:hover { background: $color-gray-45; } + @include dark-keyboard-mode { + &:focus { + background: $color-gray-45; + } + } } &__dot { @@ -146,14 +121,101 @@ } } } + } - &--selected { - @include light-theme { - background: $color-black-alpha-20; + &--picker-style { + z-index: 2; + + #{$root-selector}__button { + @media (prefers-reduced-motion: no-preference) { + // Prevent animation jank + opacity: 0; + + animation: { + name: module-ReactionPicker__button-appear; + duration: 400ms; + timing-function: $ease-out-expo; + fill-mode: forwards; + // This delay is a fallback in case there are more than the expected number of + // buttons. + delay: #{$max-expected-buttons * 10ms}; + } } - @include dark-theme { - background: $color-white-alpha-20; + @for $i from 0 through $max-expected-buttons { + &:nth-of-type(#{$i + 1}) { + animation-delay: #{$i * 10ms}; + } + } + + &--emoji { + @mixin focus-or-hover-styles { + .module-emoji { + transform: scale($big-emoji-size / $emoji-size-from-component) + translateY(-16px); + } + } + &:hover { + @include focus-or-hover-styles; + } + @include keyboard-mode { + &:focus { + @include focus-or-hover-styles; + } + } + } + + &--selected { + @include light-theme { + background: $color-black-alpha-20; + } + + @include dark-theme { + background: $color-white-alpha-20; + } + } + } + } + + &--menu-style { + #{$root-selector}__button { + $light-focus-or-hover-background: $color-black-alpha-20; + $dark-focus-or-hover-background: $color-white-alpha-40; + + &:hover { + @include light-theme { + background: $light-focus-or-hover-background; + } + @include dark-theme { + background: $dark-focus-or-hover-background; + } + } + @include keyboard-mode { + &:focus { + background: $light-focus-or-hover-background; + } + } + @include dark-keyboard-mode { + &:focus { + background: $dark-focus-or-hover-background; + } + } + + #{$root-selector}--something-selected { + opacity: 0.4; + } + + &--selected { + opacity: 1; + + .module-emoji { + transform: scale($big-emoji-size / $emoji-size-from-component); + } + + @media (prefers-reduced-motion: no-preference) { + animation: module-ReactionPicker__button-selected 1s ease-in-out + infinite alternate; + } } } } @@ -180,3 +242,13 @@ opacity: 1; } } + +@keyframes module-ReactionPicker__button-selected { + from { + transform: rotate(-8deg); + } + + to { + transform: rotate(8deg); + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index c96409a3f..76ee708ae 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -51,6 +51,7 @@ @import './components/ContactSpoofingReviewDialogPerson.scss'; @import './components/ConversationHeader.scss'; @import './components/CustomColorEditor.scss'; +@import './components/CustomizingPreferredReactionsModal.scss'; @import './components/DisappearingTimeDialog.scss'; @import './components/DisappearingTimerSelect.scss'; @import './components/EditConversationAttributesModal.scss'; diff --git a/ts/background.ts b/ts/background.ts index 283626569..f4a121f15 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -89,6 +89,7 @@ import { SendStatus, } from './messages/MessageSendState'; import * as AttachmentDownloads from './messageModifiers/AttachmentDownloads'; +import * as preferredReactions from './state/ducks/preferredReactions'; import * as Stickers from './types/Stickers'; import { SignalService as Proto } from './protobuf'; import { onRetryRequest, onDecryptionError } from './util/handleRetry'; @@ -953,6 +954,7 @@ export async function startApp(): Promise { }, emojis: window.Signal.Emojis.getInitialState(), items: window.storage.getItemsState(), + preferredReactions: preferredReactions.getInitialState(), stickers: Stickers.getInitialState(), user: { attachmentsPath: window.baseAttachmentsPath, diff --git a/ts/components/App.tsx b/ts/components/App.tsx index 304d9bebf..c824cb17c 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -24,8 +24,10 @@ export const App = ({ conversationsStoppingMessageSendBecauseOfVerification, hasInitialLoadCompleted, i18n, + isCustomizingPreferredReactions, numberOfMessagesPendingBecauseOfVerification, renderCallManager, + renderCustomizingPreferredReactionsModal, renderGlobalModalContainer, renderSafetyNumber, theme, @@ -48,9 +50,13 @@ export const App = ({ } hasInitialLoadCompleted={hasInitialLoadCompleted} i18n={i18n} + isCustomizingPreferredReactions={isCustomizingPreferredReactions} numberOfMessagesPendingBecauseOfVerification={ numberOfMessagesPendingBecauseOfVerification } + renderCustomizingPreferredReactionsModal={ + renderCustomizingPreferredReactionsModal + } renderSafetyNumber={renderSafetyNumber} verifyConversationsStoppingMessageSend={ verifyConversationsStoppingMessageSend diff --git a/ts/components/CustomizingPreferredReactionsModal.stories.tsx b/ts/components/CustomizingPreferredReactionsModal.stories.tsx new file mode 100644 index 000000000..21aba8531 --- /dev/null +++ b/ts/components/CustomizingPreferredReactionsModal.stories.tsx @@ -0,0 +1,71 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ComponentProps } from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +import { CustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal'; + +const i18n = setupI18n('en', enMessages); +const story = storiesOf( + 'Components/CustomizingPreferredReactionsModal', + module +); + +const defaultProps: ComponentProps< + typeof CustomizingPreferredReactionsModal +> = { + cancelCustomizePreferredReactionsModal: action( + 'cancelCustomizePreferredReactionsModal' + ), + deselectDraftEmoji: action('deselectDraftEmoji'), + draftPreferredReactions: [ + 'sparkles', + 'sparkle', + 'sparkler', + 'shark', + 'sparkling_heart', + 'thumbsup', + ], + hadSaveError: false, + i18n, + isSaving: false, + onSetSkinTone: action('onSetSkinTone'), + originalPreferredReactions: [ + 'heart', + 'thumbsup', + 'thumbsdown', + 'joy', + 'open_mouth', + 'cry', + ], + replaceSelectedDraftEmoji: action('replaceSelectedDraftEmoji'), + resetDraftEmoji: action('resetDraftEmoji'), + savePreferredReactions: action('savePreferredReactions'), + selectDraftEmojiToBeReplaced: action('selectDraftEmojiToBeReplaced'), + selectedDraftEmojiIndex: undefined, + skinTone: 4, +}; + +story.add('Default', () => ( + +)); + +story.add('Draft emoji selected', () => ( + +)); + +story.add('Saving', () => ( + +)); + +story.add('Had error', () => ( + +)); diff --git a/ts/components/CustomizingPreferredReactionsModal.tsx b/ts/components/CustomizingPreferredReactionsModal.tsx new file mode 100644 index 000000000..b2298330a --- /dev/null +++ b/ts/components/CustomizingPreferredReactionsModal.tsx @@ -0,0 +1,193 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useEffect, useState } from 'react'; +import { usePopper } from 'react-popper'; +import { isEqual, noop } from 'lodash'; + +import type { LocalizerType } from '../types/Util'; +import { Modal } from './Modal'; +import { Button, ButtonVariant } from './Button'; +import { + ReactionPicker, + ReactionPickerSelectionStyle, +} from './conversation/ReactionPicker'; +import { EmojiPicker } from './emoji/EmojiPicker'; +import { convertShortName } from './emoji/lib'; +import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants'; +import { offsetDistanceModifier } from '../util/popperUtil'; + +type PropsType = { + draftPreferredReactions: Array; + hadSaveError: boolean; + i18n: LocalizerType; + isSaving: boolean; + originalPreferredReactions: Array; + selectedDraftEmojiIndex: undefined | number; + skinTone: number; + + cancelCustomizePreferredReactionsModal(): unknown; + deselectDraftEmoji(): unknown; + onSetSkinTone(tone: number): unknown; + replaceSelectedDraftEmoji(newEmoji: string): unknown; + resetDraftEmoji(): unknown; + savePreferredReactions(): unknown; + selectDraftEmojiToBeReplaced(index: number): unknown; +}; + +export function CustomizingPreferredReactionsModal({ + cancelCustomizePreferredReactionsModal, + deselectDraftEmoji, + draftPreferredReactions, + hadSaveError, + i18n, + isSaving, + onSetSkinTone, + originalPreferredReactions, + replaceSelectedDraftEmoji, + resetDraftEmoji, + savePreferredReactions, + selectDraftEmojiToBeReplaced, + selectedDraftEmojiIndex, + skinTone, +}: Readonly): JSX.Element { + const [ + referenceElement, + setReferenceElement, + ] = useState(null); + const [popperElement, setPopperElement] = useState( + null + ); + const emojiPickerPopper = usePopper(referenceElement, popperElement, { + placement: 'bottom', + modifiers: [ + offsetDistanceModifier(8), + { + name: 'preventOverflow', + options: { altAxis: true }, + }, + ], + }); + + const isSomethingSelected = selectedDraftEmojiIndex !== undefined; + + useEffect(() => { + if (!isSomethingSelected) { + return noop; + } + + const onBodyClick = (event: MouseEvent) => { + const { target } = event; + if (!(target instanceof HTMLElement) || !popperElement) { + return; + } + + const isClickOutsidePicker = !popperElement.contains(target); + if (isClickOutsidePicker) { + deselectDraftEmoji(); + } + }; + + document.body.addEventListener('click', onBodyClick); + return () => { + document.body.removeEventListener('click', onBodyClick); + }; + }, [isSomethingSelected, popperElement, deselectDraftEmoji]); + + const emojis = draftPreferredReactions.map(shortName => + convertShortName(shortName, skinTone) + ); + + const selected = + typeof selectedDraftEmojiIndex === 'number' + ? emojis[selectedDraftEmojiIndex] + : undefined; + + const onPick = isSaving + ? noop + : (pickedEmoji: string) => { + selectDraftEmojiToBeReplaced( + emojis.findIndex(emoji => emoji === pickedEmoji) + ); + }; + + const hasChanged = !isEqual( + originalPreferredReactions, + draftPreferredReactions + ); + const canReset = + !isSaving && + !isEqual(DEFAULT_PREFERRED_REACTION_EMOJI, draftPreferredReactions); + const canSave = !isSaving && hasChanged; + + return ( + { + cancelCustomizePreferredReactionsModal(); + }} + title={i18n('CustomizingPreferredReactions__title')} + > +
+ + {hadSaveError + ? i18n('CustomizingPreferredReactions__had-save-error') + : i18n('CustomizingPreferredReactions__subtitle')} +
+ {isSomethingSelected && ( +
+ { + replaceSelectedDraftEmoji(shortName); + }} + skinTone={skinTone} + onSetSkinTone={onSetSkinTone} + onClose={() => { + deselectDraftEmoji(); + }} + /> +
+ )} + + + + +
+ ); +} + +function shouldNotBeCalled(): React.ReactElement { + throw new Error('This should not be called'); +} diff --git a/ts/components/Inbox.tsx b/ts/components/Inbox.tsx index 42ca085d6..4586628ea 100644 --- a/ts/components/Inbox.tsx +++ b/ts/components/Inbox.tsx @@ -24,7 +24,9 @@ export type PropsType = { conversationsStoppingMessageSendBecauseOfVerification: Array; hasInitialLoadCompleted: boolean; i18n: LocalizerType; + isCustomizingPreferredReactions: boolean; numberOfMessagesPendingBecauseOfVerification: number; + renderCustomizingPreferredReactionsModal: () => JSX.Element; renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element; verifyConversationsStoppingMessageSend: () => void; }; @@ -34,7 +36,9 @@ export const Inbox = ({ conversationsStoppingMessageSendBecauseOfVerification, hasInitialLoadCompleted, i18n, + isCustomizingPreferredReactions, numberOfMessagesPendingBecauseOfVerification, + renderCustomizingPreferredReactionsModal, renderSafetyNumber, verifyConversationsStoppingMessageSend, }: PropsType): JSX.Element => { @@ -67,7 +71,7 @@ export const Inbox = ({ } }, [hasInitialLoadCompleted, viewRef]); - let safetyNumberChangeDialog: ReactNode; + let activeModal: ReactNode; if (conversationsStoppingMessageSendBecauseOfVerification.length) { const confirmText: string = numberOfMessagesPendingBecauseOfVerification === 1 @@ -75,7 +79,7 @@ export const Inbox = ({ : i18n('safetyNumberChangeDialog__pending-messages--many', [ numberOfMessagesPendingBecauseOfVerification.toString(), ]); - safetyNumberChangeDialog = ( + activeModal = ( ); } + if (!activeModal && isCustomizingPreferredReactions) { + activeModal = renderCustomizingPreferredReactionsModal(); + } return ( <>
- {safetyNumberChangeDialog} + {activeModal} ); }; diff --git a/ts/components/conversation/ReactionPicker.stories.tsx b/ts/components/conversation/ReactionPicker.stories.tsx index 290e53bce..b9a8a7e6b 100644 --- a/ts/components/conversation/ReactionPicker.stories.tsx +++ b/ts/components/conversation/ReactionPicker.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -9,11 +9,24 @@ import { action } from '@storybook/addon-actions'; import { select } from '@storybook/addon-knobs'; import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; -import { Props as ReactionPickerProps, ReactionPicker } from './ReactionPicker'; +import { + Props as ReactionPickerProps, + ReactionPicker, + ReactionPickerSelectionStyle, +} from './ReactionPicker'; import { EmojiPicker } from '../emoji/EmojiPicker'; const i18n = setupI18n('en', enMessages); +const preferredReactionEmoji = [ + 'heart', + 'thumbsup', + 'thumbsdown', + 'joy', + 'open_mouth', + 'cry', +]; + const renderEmojiPicker: ReactionPickerProps['renderEmojiPicker'] = ({ onClose, onPickEmoji, @@ -35,7 +48,12 @@ storiesOf('Components/Conversation/ReactionPicker', module) ); @@ -47,7 +65,12 @@ storiesOf('Components/Conversation/ReactionPicker', module) i18n={i18n} selected={e} onPick={action('onPick')} + openCustomizePreferredReactionsModal={action( + 'openCustomizePreferredReactionsModal' + )} + preferredReactionEmoji={preferredReactionEmoji} renderEmojiPicker={renderEmojiPicker} + selectionStyle={ReactionPickerSelectionStyle.Picker} skinTone={0} />
@@ -60,7 +83,12 @@ storiesOf('Components/Conversation/ReactionPicker', module) i18n={i18n} selected={e} onPick={action('onPick')} + openCustomizePreferredReactionsModal={action( + 'openCustomizePreferredReactionsModal' + )} + preferredReactionEmoji={preferredReactionEmoji} renderEmojiPicker={renderEmojiPicker} + selectionStyle={ReactionPickerSelectionStyle.Picker} skinTone={select( 'skinTone', { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 }, diff --git a/ts/components/conversation/ReactionPicker.tsx b/ts/components/conversation/ReactionPicker.tsx index 68fad15b5..fe5007359 100644 --- a/ts/components/conversation/ReactionPicker.tsx +++ b/ts/components/conversation/ReactionPicker.tsx @@ -1,39 +1,41 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import classNames from 'classnames'; +import * as log from '../../logging/log'; import { Emoji } from '../emoji/Emoji'; import { convertShortName } from '../emoji/lib'; import { Props as EmojiPickerProps } from '../emoji/EmojiPicker'; +import { missingCaseError } from '../../util/missingCaseError'; import { useRestoreFocus } from '../../util/hooks/useRestoreFocus'; import { LocalizerType } from '../../types/Util'; +export enum ReactionPickerSelectionStyle { + Picker, + Menu, +} + export type RenderEmojiPickerProps = Pick & - Pick & { + Pick & { ref: React.Ref; }; export type OwnProps = { + hasMoreButton?: boolean; i18n: LocalizerType; selected?: string; + selectionStyle: ReactionPickerSelectionStyle; onClose?: () => unknown; onPick: (emoji: string) => unknown; + openCustomizePreferredReactionsModal?: () => unknown; + preferredReactionEmoji: Array; renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement; skinTone: number; }; export type Props = OwnProps & Pick, 'style'>; -const DEFAULT_EMOJI_LIST = [ - 'heart', - 'thumbsup', - 'thumbsdown', - 'joy', - 'open_mouth', - 'cry', -]; - const EmojiButton = React.forwardRef< HTMLButtonElement, { @@ -64,7 +66,19 @@ const EmojiButton = React.forwardRef< export const ReactionPicker = React.forwardRef( ( - { i18n, selected, onClose, skinTone, onPick, renderEmojiPicker, style }, + { + hasMoreButton = true, + i18n, + onClose, + onPick, + openCustomizePreferredReactionsModal, + preferredReactionEmoji, + renderEmojiPicker, + selected, + selectionStyle, + skinTone, + style, + }, ref ) => { const [pickingOther, setPickingOther] = React.useState(false); @@ -96,17 +110,25 @@ export const ReactionPicker = React.forwardRef( const [focusRef] = useRestoreFocus(); if (pickingOther) { - return renderEmojiPicker({ onPickEmoji, onClose, style, ref }); + return renderEmojiPicker({ + onClickSettings: openCustomizePreferredReactionsModal, + onClose, + onPickEmoji, + ref, + style, + }); } - const emojis = DEFAULT_EMOJI_LIST.map(shortName => + const emojis = preferredReactionEmoji.map(shortName => convertShortName(shortName, skinTone) ); const otherSelected = selected && !emojis.includes(selected); let moreButton: React.ReactNode; - if (otherSelected) { + if (!hasMoreButton) { + moreButton = undefined; + } else if (otherSelected) { moreButton = ( ( ); } + let selectionStyleClassName: string; + switch (selectionStyle) { + case ReactionPickerSelectionStyle.Picker: + selectionStyleClassName = 'module-ReactionPicker--picker-style'; + break; + case ReactionPickerSelectionStyle.Menu: + selectionStyleClassName = 'module-ReactionPicker--menu-style'; + break; + default: + log.error(missingCaseError(selectionStyle)); + selectionStyleClassName = 'module-ReactionPicker--picker-style'; + break; + } + return ( -
+
{emojis.map((emoji, index) => { const maybeFocusRef = index === 0 ? focusRef : undefined; diff --git a/ts/components/emoji/EmojiPicker.stories.tsx b/ts/components/emoji/EmojiPicker.stories.tsx index 79a4bcbe8..b40ff905e 100644 --- a/ts/components/emoji/EmojiPicker.stories.tsx +++ b/ts/components/emoji/EmojiPicker.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -70,4 +70,17 @@ storiesOf('Components/Emoji/EmojiPicker', module) recentEmojis={[]} /> ); + }) + .add('With settings button', () => { + return ( + + ); }); diff --git a/ts/components/emoji/EmojiPicker.tsx b/ts/components/emoji/EmojiPicker.tsx index 69cdc61b0..d96c3b7ba 100644 --- a/ts/components/emoji/EmojiPicker.tsx +++ b/ts/components/emoji/EmojiPicker.tsx @@ -1,4 +1,4 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -34,6 +34,7 @@ export type OwnProps = { readonly skinTone?: number; readonly onSetSkinTone?: (tone: number) => unknown; readonly recentEmojis?: Array; + readonly onClickSettings?: () => unknown; readonly onClose?: () => unknown; }; @@ -70,6 +71,7 @@ export const EmojiPicker = React.memo( onSetSkinTone, recentEmojis = [], style, + onClickSettings, onClose, }: Props, ref @@ -383,24 +385,38 @@ export const EmojiPicker = React.memo(
)}
- {[0, 1, 2, 3, 4, 5].map(tone => ( + {Boolean(onClickSettings) && ( - ))} + /> + )} +
+ {[0, 1, 2, 3, 4, 5].map(tone => ( + + ))} +
+ {Boolean(onClickSettings) && ( +
+ )}
); diff --git a/ts/reactions/constants.ts b/ts/reactions/constants.ts new file mode 100644 index 000000000..7e34ea72f --- /dev/null +++ b/ts/reactions/constants.ts @@ -0,0 +1,11 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export const DEFAULT_PREFERRED_REACTION_EMOJI = [ + 'heart', + 'thumbsup', + 'thumbsdown', + 'joy', + 'open_mouth', + 'cry', +]; diff --git a/ts/reactions/getPreferredReactionEmoji.ts b/ts/reactions/getPreferredReactionEmoji.ts new file mode 100644 index 000000000..55d9a9506 --- /dev/null +++ b/ts/reactions/getPreferredReactionEmoji.ts @@ -0,0 +1,20 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { DEFAULT_PREFERRED_REACTION_EMOJI } from './constants'; +import * as emoji from '../components/emoji/lib'; + +const PREFERRED_REACTION_EMOJI_COUNT = DEFAULT_PREFERRED_REACTION_EMOJI.length; + +export function getPreferredReactionEmoji(storedValue: unknown): Array { + const isStoredValueValid = + Array.isArray(storedValue) && + storedValue.length === PREFERRED_REACTION_EMOJI_COUNT && + storedValue.every(emoji.isShortName) && + !hasDuplicates(storedValue); + return isStoredValueValid ? storedValue : DEFAULT_PREFERRED_REACTION_EMOJI; +} + +function hasDuplicates(arr: ReadonlyArray): boolean { + return new Set(arr).size !== arr.length; +} diff --git a/ts/state/ducks/items.ts b/ts/state/ducks/items.ts index 8c93fbeaf..0baa6e8d0 100644 --- a/ts/state/ducks/items.ts +++ b/ts/state/ducks/items.ts @@ -29,6 +29,8 @@ export type ItemsStateType = { readonly defaultConversationColor?: DefaultConversationColorType; readonly customColors?: CustomColorsItemType; + + readonly preferredReactionEmoji?: Array; }; // Actions diff --git a/ts/state/ducks/preferredReactions.ts b/ts/state/ducks/preferredReactions.ts new file mode 100644 index 000000000..c0fa74ef5 --- /dev/null +++ b/ts/state/ducks/preferredReactions.ts @@ -0,0 +1,315 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ThunkAction } from 'redux-thunk'; +import { omit } from 'lodash'; +import * as log from '../../logging/log'; +import * as Errors from '../../types/errors'; +import { replaceIndex } from '../../util/replaceIndex'; +import { useBoundActions } from '../../util/hooks'; +import type { StateType as RootStateType } from '../reducer'; +import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../../reactions/constants'; +import { getPreferredReactionEmoji } from '../../reactions/getPreferredReactionEmoji'; + +// State + +export type PreferredReactionsStateType = { + customizePreferredReactionsModal?: { + draftPreferredReactions: Array; + originalPreferredReactions: Array; + selectedDraftEmojiIndex: undefined | number; + } & ( + | { isSaving: true; hadSaveError: false } + | { isSaving: false; hadSaveError: boolean } + ); +}; + +// Actions + +const CANCEL_CUSTOMIZE_PREFERRED_REACTIONS_MODAL = + 'preferredReactions/CANCEL_CUSTOMIZE_PREFERRED_REACTIONS_MODAL'; +const DESELECT_DRAFT_EMOJI = 'preferredReactions/DESELECT_DRAFT_EMOJI'; +const OPEN_CUSTOMIZE_PREFERRED_REACTIONS_MODAL = + 'preferredReactions/OPEN_CUSTOMIZE_PREFERRED_REACTIONS_MODAL'; +const REPLACE_SELECTED_DRAFT_EMOJI = + 'preferredReactions/REPLACE_SELECTED_DRAFT_EMOJI'; +const RESET_DRAFT_EMOJI = 'preferredReactions/RESET_DRAFT_EMOJI'; +const SAVE_PREFERRED_REACTIONS_FULFILLED = + 'preferredReactions/SAVE_PREFERRED_REACTIONS_FULFILLED'; +const SAVE_PREFERRED_REACTIONS_PENDING = + 'preferredReactions/SAVE_PREFERRED_REACTIONS_PENDING'; +const SAVE_PREFERRED_REACTIONS_REJECTED = + 'preferredReactions/SAVE_PREFERRED_REACTIONS_REJECTED'; +const SELECT_DRAFT_EMOJI_TO_BE_REPLACED = + 'preferredReactions/SELECT_DRAFT_EMOJI_TO_BE_REPLACED'; + +type CancelCustomizePreferredReactionsModalActionType = { + type: typeof CANCEL_CUSTOMIZE_PREFERRED_REACTIONS_MODAL; +}; + +type DeselectDraftEmojiActionType = { type: typeof DESELECT_DRAFT_EMOJI }; + +type OpenCustomizePreferredReactionsModalActionType = { + type: typeof OPEN_CUSTOMIZE_PREFERRED_REACTIONS_MODAL; + payload: { + originalPreferredReactions: Array; + }; +}; + +type ReplaceSelectedDraftEmojiActionType = { + type: typeof REPLACE_SELECTED_DRAFT_EMOJI; + payload: string; +}; + +type ResetDraftEmojiActionType = { type: typeof RESET_DRAFT_EMOJI }; + +type SavePreferredReactionsFulfilledActionType = { + type: typeof SAVE_PREFERRED_REACTIONS_FULFILLED; +}; + +type SavePreferredReactionsPendingActionType = { + type: typeof SAVE_PREFERRED_REACTIONS_PENDING; +}; + +type SavePreferredReactionsRejectedActionType = { + type: typeof SAVE_PREFERRED_REACTIONS_REJECTED; +}; + +type SelectDraftEmojiToBeReplacedActionType = { + type: typeof SELECT_DRAFT_EMOJI_TO_BE_REPLACED; + payload: number; +}; + +// Action creators + +export const actions = { + cancelCustomizePreferredReactionsModal, + deselectDraftEmoji, + openCustomizePreferredReactionsModal, + replaceSelectedDraftEmoji, + resetDraftEmoji, + savePreferredReactions, + selectDraftEmojiToBeReplaced, +}; + +export const useActions = (): typeof actions => useBoundActions(actions); + +function cancelCustomizePreferredReactionsModal(): CancelCustomizePreferredReactionsModalActionType { + return { type: CANCEL_CUSTOMIZE_PREFERRED_REACTIONS_MODAL }; +} + +function deselectDraftEmoji(): DeselectDraftEmojiActionType { + return { type: DESELECT_DRAFT_EMOJI }; +} + +function openCustomizePreferredReactionsModal(): ThunkAction< + void, + RootStateType, + unknown, + OpenCustomizePreferredReactionsModalActionType +> { + return (dispatch, getState) => { + const originalPreferredReactions = getPreferredReactionEmoji( + getState().items.preferredReactionEmoji + ); + dispatch({ + type: OPEN_CUSTOMIZE_PREFERRED_REACTIONS_MODAL, + payload: { originalPreferredReactions }, + }); + }; +} + +function replaceSelectedDraftEmoji( + newEmoji: string +): ReplaceSelectedDraftEmojiActionType { + return { + type: REPLACE_SELECTED_DRAFT_EMOJI, + payload: newEmoji, + }; +} + +function resetDraftEmoji(): ResetDraftEmojiActionType { + return { type: RESET_DRAFT_EMOJI }; +} + +function savePreferredReactions(): ThunkAction< + void, + RootStateType, + unknown, + | SavePreferredReactionsFulfilledActionType + | SavePreferredReactionsPendingActionType + | SavePreferredReactionsRejectedActionType +> { + return async (dispatch, getState) => { + const { draftPreferredReactions } = + getState().preferredReactions.customizePreferredReactionsModal || {}; + if (!draftPreferredReactions) { + log.error( + "savePreferredReactions won't work because the modal is not open" + ); + return; + } + + dispatch({ type: SAVE_PREFERRED_REACTIONS_PENDING }); + try { + await window.storage.put( + 'preferredReactionEmoji', + draftPreferredReactions + ); + dispatch({ type: SAVE_PREFERRED_REACTIONS_FULFILLED }); + } catch (err: unknown) { + log.warn(Errors.toLogFormat(err)); + dispatch({ type: SAVE_PREFERRED_REACTIONS_REJECTED }); + } + }; +} + +function selectDraftEmojiToBeReplaced( + index: number +): SelectDraftEmojiToBeReplacedActionType { + return { + type: SELECT_DRAFT_EMOJI_TO_BE_REPLACED, + payload: index, + }; +} + +// Reducer + +export function getInitialState(): PreferredReactionsStateType { + return {}; +} + +export function reducer( + state: Readonly = getInitialState(), + action: Readonly< + | CancelCustomizePreferredReactionsModalActionType + | DeselectDraftEmojiActionType + | OpenCustomizePreferredReactionsModalActionType + | ReplaceSelectedDraftEmojiActionType + | ResetDraftEmojiActionType + | SavePreferredReactionsFulfilledActionType + | SavePreferredReactionsPendingActionType + | SavePreferredReactionsRejectedActionType + | SelectDraftEmojiToBeReplacedActionType + > +): PreferredReactionsStateType { + switch (action.type) { + case CANCEL_CUSTOMIZE_PREFERRED_REACTIONS_MODAL: + case SAVE_PREFERRED_REACTIONS_FULFILLED: + return omit(state, ['customizePreferredReactionsModal']); + case DESELECT_DRAFT_EMOJI: + if (!state.customizePreferredReactionsModal) { + return state; + } + return { + ...state, + customizePreferredReactionsModal: { + ...state.customizePreferredReactionsModal, + selectedDraftEmojiIndex: undefined, + }, + }; + case OPEN_CUSTOMIZE_PREFERRED_REACTIONS_MODAL: { + const { originalPreferredReactions } = action.payload; + return { + ...state, + customizePreferredReactionsModal: { + draftPreferredReactions: originalPreferredReactions, + originalPreferredReactions, + selectedDraftEmojiIndex: undefined, + isSaving: false, + hadSaveError: false, + }, + }; + } + case REPLACE_SELECTED_DRAFT_EMOJI: { + const newEmoji = action.payload; + + const { customizePreferredReactionsModal } = state; + if (!customizePreferredReactionsModal) { + return state; + } + + const { + draftPreferredReactions, + selectedDraftEmojiIndex, + } = customizePreferredReactionsModal; + if ( + selectedDraftEmojiIndex === undefined || + draftPreferredReactions.includes(newEmoji) + ) { + return state; + } + + return { + ...state, + customizePreferredReactionsModal: { + ...customizePreferredReactionsModal, + draftPreferredReactions: replaceIndex( + draftPreferredReactions, + selectedDraftEmojiIndex, + newEmoji + ), + selectedDraftEmojiIndex: undefined, + }, + }; + } + case RESET_DRAFT_EMOJI: + if (!state.customizePreferredReactionsModal) { + return state; + } + return { + ...state, + customizePreferredReactionsModal: { + ...state.customizePreferredReactionsModal, + draftPreferredReactions: DEFAULT_PREFERRED_REACTION_EMOJI, + selectedDraftEmojiIndex: undefined, + }, + }; + case SAVE_PREFERRED_REACTIONS_PENDING: + if (!state.customizePreferredReactionsModal) { + return state; + } + return { + ...state, + customizePreferredReactionsModal: { + ...state.customizePreferredReactionsModal, + selectedDraftEmojiIndex: undefined, + isSaving: true, + hadSaveError: false, + }, + }; + case SAVE_PREFERRED_REACTIONS_REJECTED: + if (!state.customizePreferredReactionsModal) { + return state; + } + return { + ...state, + customizePreferredReactionsModal: { + ...state.customizePreferredReactionsModal, + isSaving: false, + hadSaveError: true, + }, + }; + case SELECT_DRAFT_EMOJI_TO_BE_REPLACED: { + const index = action.payload; + if ( + !state.customizePreferredReactionsModal || + !( + index in + state.customizePreferredReactionsModal.draftPreferredReactions + ) + ) { + return state; + } + return { + ...state, + customizePreferredReactionsModal: { + ...state.customizePreferredReactionsModal, + selectedDraftEmojiIndex: index, + }, + }; + } + default: + return state; + } +} diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 060315a36..4470c005a 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -1,4 +1,4 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { combineReducers } from 'redux'; @@ -15,6 +15,7 @@ import { reducer as globalModals } from './ducks/globalModals'; import { reducer as items } from './ducks/items'; import { reducer as linkPreviews } from './ducks/linkPreviews'; import { reducer as network } from './ducks/network'; +import { reducer as preferredReactions } from './ducks/preferredReactions'; import { reducer as safetyNumber } from './ducks/safetyNumber'; import { reducer as search } from './ducks/search'; import { reducer as stickers } from './ducks/stickers'; @@ -34,6 +35,7 @@ export const reducer = combineReducers({ items, linkPreviews, network, + preferredReactions, safetyNumber, search, stickers, diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 12a0c3937..d4326ae2b 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { createSelector } from 'reselect'; +import { isInteger } from 'lodash'; import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer'; @@ -12,6 +13,7 @@ import { CustomColorType, DEFAULT_CONVERSATION_COLOR, } from '../../types/Colors'; +import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/getPreferredReactionEmoji'; export const getItems = (state: StateType): ItemsStateType => state.items; @@ -49,3 +51,20 @@ export const getCustomColors = createSelector( (state: ItemsStateType): Record | undefined => state.customColors?.colors ); + +export const getEmojiSkinTone = createSelector( + getItems, + ({ skinTone }: Readonly): number => + typeof skinTone === 'number' && + isInteger(skinTone) && + skinTone >= 0 && + skinTone <= 5 + ? skinTone + : 0 +); + +export const getPreferredReactionEmoji = createSelector( + getItems, + (state: Readonly): Array => + getPreferredReactionEmojiFromStoredValue(state.preferredReactionEmoji) +); diff --git a/ts/state/selectors/preferredReactions.ts b/ts/state/selectors/preferredReactions.ts new file mode 100644 index 000000000..992e2c85b --- /dev/null +++ b/ts/state/selectors/preferredReactions.ts @@ -0,0 +1,22 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { createSelector } from 'reselect'; + +import type { StateType } from '../reducer'; +import type { PreferredReactionsStateType } from '../ducks/preferredReactions'; + +const getPreferredReactionsState = ( + state: Readonly +): PreferredReactionsStateType => state.preferredReactions; + +export const getCustomizeModalState = createSelector( + getPreferredReactionsState, + (state: Readonly) => + state.customizePreferredReactionsModal +); + +export const getIsCustomizingPreferredReactions = createSelector( + getCustomizeModalState, + (customizeModal): boolean => Boolean(customizeModal) +); diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index 68abcefbc..86a8584a2 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -6,6 +6,7 @@ import { connect } from 'react-redux'; import { App } from '../../components/App'; import { SmartCallManager } from './CallManager'; +import { SmartCustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal'; import { SmartGlobalModalContainer } from './GlobalModalContainer'; import { SmartSafetyNumberViewer } from './SafetyNumberViewer'; import { StateType } from '../reducer'; @@ -14,6 +15,7 @@ import { getConversationsStoppingMessageSendBecauseOfVerification, getNumberOfMessagesPendingBecauseOfVerification, } from '../selectors/conversations'; +import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions'; import { mapDispatchToProps } from '../actions'; import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog'; @@ -24,10 +26,14 @@ const mapStateToProps = (state: StateType) => { state ), i18n: getIntl(state), + isCustomizingPreferredReactions: getIsCustomizingPreferredReactions(state), numberOfMessagesPendingBecauseOfVerification: getNumberOfMessagesPendingBecauseOfVerification( state ), renderCallManager: () => , + renderCustomizingPreferredReactionsModal: () => ( + + ), renderGlobalModalContainer: () => , renderSafetyNumber: (props: SafetyNumberProps) => ( diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 3cbe563fc..71fbcd305 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -10,6 +10,7 @@ import { isConversationSMSOnly } from '../../util/isConversationSMSOnly'; import { selectRecentEmojis } from '../selectors/emojis'; import { getIntl, getUserConversationId } from '../selectors/user'; +import { getEmojiSkinTone } from '../selectors/items'; import { getConversationSelector, getGroupAdminsSelector, @@ -100,7 +101,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { }, // Emojis recentEmojis, - skinTone: get(state, ['items', 'skinTone'], 0), + skinTone: getEmojiSkinTone(state), // Stickers receivedPacks, installedPack, diff --git a/ts/state/smart/CustomizingPreferredReactionsModal.tsx b/ts/state/smart/CustomizingPreferredReactionsModal.tsx new file mode 100644 index 000000000..961227a6f --- /dev/null +++ b/ts/state/smart/CustomizingPreferredReactionsModal.tsx @@ -0,0 +1,46 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { useSelector } from 'react-redux'; + +import type { StateType } from '../reducer'; +import type { LocalizerType } from '../../types/Util'; +import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions'; +import { useActions as useItemsActions } from '../ducks/items'; +import { getIntl } from '../selectors/user'; +import { getEmojiSkinTone } from '../selectors/items'; +import { getCustomizeModalState } from '../selectors/preferredReactions'; + +import { CustomizingPreferredReactionsModal } from '../../components/CustomizingPreferredReactionsModal'; + +export function SmartCustomizingPreferredReactionsModal(): JSX.Element { + const preferredReactionsActions = usePreferredReactionsActions(); + const { onSetSkinTone } = useItemsActions(); + + const i18n = useSelector(getIntl); + + const customizeModalState = useSelector< + StateType, + ReturnType + >(state => getCustomizeModalState(state)); + if (!customizeModalState) { + throw new Error( + ' requires a modal' + ); + } + + const skinTone = useSelector(state => + getEmojiSkinTone(state) + ); + + return ( + + ); +} diff --git a/ts/state/smart/EmojiPicker.tsx b/ts/state/smart/EmojiPicker.tsx index 9bfe73d51..54f7a9864 100644 --- a/ts/state/smart/EmojiPicker.tsx +++ b/ts/state/smart/EmojiPicker.tsx @@ -1,9 +1,8 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import { useSelector } from 'react-redux'; -import { get } from 'lodash'; import { StateType } from '../reducer'; import { useRecentEmojis } from '../selectors/emojis'; import { useActions as useEmojiActions } from '../ducks/emojis'; @@ -13,15 +12,19 @@ import { Props as EmojiPickerProps, } from '../../components/emoji/EmojiPicker'; import { getIntl } from '../selectors/user'; +import { getEmojiSkinTone } from '../selectors/items'; import { LocalizerType } from '../../types/Util'; export const SmartEmojiPicker = React.forwardRef< HTMLDivElement, - Pick ->(({ onPickEmoji, onSetSkinTone, onClose, style }, ref) => { + Pick< + EmojiPickerProps, + 'onClickSettings' | 'onPickEmoji' | 'onSetSkinTone' | 'onClose' | 'style' + > +>(({ onClickSettings, onPickEmoji, onSetSkinTone, onClose, style }, ref) => { const i18n = useSelector(getIntl); const skinTone = useSelector(state => - get(state, ['items', 'skinTone'], 0) + getEmojiSkinTone(state) ); const recentEmojis = useRecentEmojis(); @@ -41,6 +44,7 @@ export const SmartEmojiPicker = React.forwardRef< ref={ref} i18n={i18n} skinTone={skinTone} + onClickSettings={onClickSettings} onSetSkinTone={onSetSkinTone} onPickEmoji={handlePickEmoji} recentEmojis={recentEmojis} diff --git a/ts/state/smart/ForwardMessageModal.tsx b/ts/state/smart/ForwardMessageModal.tsx index 9bf1c47ab..cad596540 100644 --- a/ts/state/smart/ForwardMessageModal.tsx +++ b/ts/state/smart/ForwardMessageModal.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { connect } from 'react-redux'; -import { get } from 'lodash'; import { mapDispatchToProps } from '../actions'; import { ForwardMessageModal, @@ -14,6 +13,7 @@ import { LinkPreviewType } from '../../types/message/LinkPreviews'; import { getAllComposableConversations } from '../selectors/conversations'; import { getLinkPreview } from '../selectors/linkPreviews'; import { getIntl } from '../selectors/user'; +import { getEmojiSkinTone } from '../selectors/items'; import { selectRecentEmojis } from '../selectors/emojis'; import { AttachmentType } from '../../types/Attachment'; @@ -52,7 +52,7 @@ const mapStateToProps = ( const candidateConversations = getAllComposableConversations(state); const recentEmojis = selectRecentEmojis(state); - const skinTone = get(state, ['items', 'skinTone'], 0); + const skinTone = getEmojiSkinTone(state); const linkPreview = getLinkPreview(state); return { diff --git a/ts/state/smart/ProfileEditorModal.ts b/ts/state/smart/ProfileEditorModal.ts index f1f07b792..937fe01f8 100644 --- a/ts/state/smart/ProfileEditorModal.ts +++ b/ts/state/smart/ProfileEditorModal.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { connect } from 'react-redux'; -import { get } from 'lodash'; import { mapDispatchToProps } from '../actions'; import { ProfileEditorModal, @@ -11,6 +10,7 @@ import { import { PropsDataType } from '../../components/ProfileEditor'; import { StateType } from '../reducer'; import { getIntl } from '../selectors/user'; +import { getEmojiSkinTone } from '../selectors/items'; import { getMe } from '../selectors/conversations'; import { selectRecentEmojis } from '../selectors/emojis'; @@ -28,7 +28,7 @@ function mapStateToProps( id: conversationId, } = getMe(state); const recentEmojis = selectRecentEmojis(state); - const skinTone = get(state, ['items', 'skinTone'], 0); + const skinTone = getEmojiSkinTone(state); return { aboutEmoji, diff --git a/ts/state/smart/ReactionPicker.tsx b/ts/state/smart/ReactionPicker.tsx index f661c3ca3..273baabe9 100644 --- a/ts/state/smart/ReactionPicker.tsx +++ b/ts/state/smart/ReactionPicker.tsx @@ -1,32 +1,62 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import { useSelector } from 'react-redux'; -import { get } from 'lodash'; import { StateType } from '../reducer'; +import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions'; import { getIntl } from '../selectors/user'; +import { + getEmojiSkinTone, + getPreferredReactionEmoji, +} from '../selectors/items'; import { LocalizerType } from '../../types/Util'; import { ReactionPicker, + ReactionPickerSelectionStyle, Props, } from '../../components/conversation/ReactionPicker'; -type ExternalProps = Omit; +type ExternalProps = Omit< + Props, + | 'i18n' + | 'openCustomizePreferredReactionsModal' + | 'preferredReactionEmoji' + | 'selectionStyle' + | 'skinTone' +>; export const SmartReactionPicker = React.forwardRef< HTMLDivElement, ExternalProps >((props, ref) => { + const { + openCustomizePreferredReactionsModal, + } = usePreferredReactionsActions(); + const i18n = useSelector(getIntl); + const preferredReactionEmoji = useSelector>( + getPreferredReactionEmoji + ); + const skinTone = useSelector(state => - get(state, ['items', 'skinTone'], 0) + getEmojiSkinTone(state) ); return ( - + ); }); diff --git a/ts/state/smart/renderEmojiPicker.tsx b/ts/state/smart/renderEmojiPicker.tsx index a5bb805b0..6397f9629 100644 --- a/ts/state/smart/renderEmojiPicker.tsx +++ b/ts/state/smart/renderEmojiPicker.tsx @@ -8,6 +8,7 @@ import { SmartEmojiPicker } from './EmojiPicker'; export function renderEmojiPicker({ ref, + onClickSettings, onPickEmoji, onClose, style, @@ -15,6 +16,7 @@ export function renderEmojiPicker({ return ( { + it('returns the default set if passed anything invalid', () => { + [ + // Invalid types + undefined, + null, + DEFAULT_PREFERRED_REACTION_EMOJI.join(','), + // Invalid lengths + [], + DEFAULT_PREFERRED_REACTION_EMOJI.slice(0, 3), + [...DEFAULT_PREFERRED_REACTION_EMOJI, 'sparkles'], + // Non-strings in the array + ['heart', 'thumbsdown', undefined, 'joy', 'open_mouth', 'cry'], + ['heart', 'thumbsdown', 99, 'joy', 'open_mouth', 'cry'], + // Invalid emoji + ['heart', 'thumbsdown', 'gorbage!!', 'joy', 'open_mouth', 'cry'], + // Has duplicates + ['heart', 'thumbsdown', 'joy', 'joy', 'open_mouth', 'cry'], + ].forEach(input => { + assert.deepStrictEqual( + getPreferredReactionEmoji(input), + DEFAULT_PREFERRED_REACTION_EMOJI + ); + }); + }); + + it('returns a custom set if passed a valid value', () => { + const input = [ + 'sparkles', + 'sparkle', + 'sparkler', + 'shark', + 'sparkling_heart', + 'parking', + ]; + assert.deepStrictEqual(getPreferredReactionEmoji(input), input); + }); +}); diff --git a/ts/test-both/state/ducks/preferredReactions_test.ts b/ts/test-both/state/ducks/preferredReactions_test.ts new file mode 100644 index 000000000..14efa8040 --- /dev/null +++ b/ts/test-both/state/ducks/preferredReactions_test.ts @@ -0,0 +1,424 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { reducer as rootReducer } from '../../../state/reducer'; +import { noopAction } from '../../../state/ducks/noop'; +import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../../../reactions/constants'; + +import { + PreferredReactionsStateType, + actions, + getInitialState, + reducer, +} from '../../../state/ducks/preferredReactions'; + +describe('preferred reactions duck', () => { + const getEmptyRootState = () => rootReducer(undefined, noopAction()); + + const getRootState = (preferredReactions: PreferredReactionsStateType) => ({ + ...getEmptyRootState(), + preferredReactions, + }); + + const stateWithOpenCustomizationModal = { + ...getInitialState(), + customizePreferredReactionsModal: { + draftPreferredReactions: [ + 'sparkles', + 'sparkle', + 'sparkler', + 'shark', + 'sparkling_heart', + 'parking', + ], + originalPreferredReactions: [ + 'blue_heart', + 'thumbsup', + 'thumbsdown', + 'joy', + 'open_mouth', + 'cry', + ], + selectedDraftEmojiIndex: undefined, + isSaving: false as const, + hadSaveError: false, + }, + }; + + const stateWithOpenCustomizationModalAndSelectedEmoji = { + ...stateWithOpenCustomizationModal, + customizePreferredReactionsModal: { + ...stateWithOpenCustomizationModal.customizePreferredReactionsModal, + selectedDraftEmojiIndex: 1, + }, + }; + + let sinonSandbox: sinon.SinonSandbox; + + beforeEach(() => { + sinonSandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sinonSandbox.restore(); + }); + + describe('cancelCustomizePreferredReactionsModal', () => { + const { cancelCustomizePreferredReactionsModal } = actions; + + it("does nothing if the modal isn't open", () => { + const action = cancelCustomizePreferredReactionsModal(); + const result = reducer(getInitialState(), action); + + assert.notProperty(result, 'customizePreferredReactionsModal'); + }); + + it('closes the modal if open', () => { + const action = cancelCustomizePreferredReactionsModal(); + const result = reducer(stateWithOpenCustomizationModal, action); + + assert.notProperty(result, 'customizePreferredReactionsModal'); + }); + }); + + describe('deselectDraftEmoji', () => { + const { deselectDraftEmoji } = actions; + + it('is a no-op if the customization modal is not open', () => { + const state = getInitialState(); + const action = deselectDraftEmoji(); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it('is a no-op if no emoji is selected', () => { + const action = deselectDraftEmoji(); + const result = reducer(stateWithOpenCustomizationModal, action); + + assert.isUndefined( + result.customizePreferredReactionsModal?.selectedDraftEmojiIndex + ); + }); + + it('deselects a currently-selected emoji', () => { + const action = deselectDraftEmoji(); + const result = reducer( + stateWithOpenCustomizationModalAndSelectedEmoji, + action + ); + + assert.isUndefined( + result.customizePreferredReactionsModal?.selectedDraftEmojiIndex + ); + }); + }); + + describe('openCustomizePreferredReactionsModal', () => { + const { openCustomizePreferredReactionsModal } = actions; + + it('opens the customization modal with defaults if no value was stored', () => { + const dispatch = sinon.spy(); + openCustomizePreferredReactionsModal()(dispatch, getEmptyRootState, null); + const [action] = dispatch.getCall(0).args; + + const result = reducer(getEmptyRootState().preferredReactions, action); + + assert.deepEqual(result.customizePreferredReactionsModal, { + draftPreferredReactions: DEFAULT_PREFERRED_REACTION_EMOJI, + originalPreferredReactions: DEFAULT_PREFERRED_REACTION_EMOJI, + selectedDraftEmojiIndex: undefined, + isSaving: false, + hadSaveError: false, + }); + }); + + it('opens the customization modal with stored values', () => { + const storedPreferredReactionEmoji = [ + 'sparkles', + 'sparkle', + 'sparkler', + 'shark', + 'sparkling_heart', + 'parking', + ]; + + const emptyRootState = getEmptyRootState(); + const state = { + ...emptyRootState, + items: { + ...emptyRootState.items, + preferredReactionEmoji: storedPreferredReactionEmoji, + }, + }; + + const dispatch = sinon.spy(); + openCustomizePreferredReactionsModal()(dispatch, () => state, null); + const [action] = dispatch.getCall(0).args; + + const result = reducer(state.preferredReactions, action); + + assert.deepEqual(result.customizePreferredReactionsModal, { + draftPreferredReactions: storedPreferredReactionEmoji, + originalPreferredReactions: storedPreferredReactionEmoji, + selectedDraftEmojiIndex: undefined, + isSaving: false, + hadSaveError: false, + }); + }); + }); + + describe('replaceSelectedDraftEmoji', () => { + const { replaceSelectedDraftEmoji } = actions; + + it('is a no-op if the customization modal is not open', () => { + const state = getInitialState(); + const action = replaceSelectedDraftEmoji('cat'); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it('is a no-op if no emoji is selected', () => { + const action = replaceSelectedDraftEmoji('cat'); + const result = reducer(stateWithOpenCustomizationModal, action); + + assert.strictEqual(result, stateWithOpenCustomizationModal); + }); + + it('is a no-op if the new emoji is already in the list', () => { + const action = replaceSelectedDraftEmoji('shark'); + const result = reducer( + stateWithOpenCustomizationModalAndSelectedEmoji, + action + ); + + assert.strictEqual( + result, + stateWithOpenCustomizationModalAndSelectedEmoji + ); + }); + + it('replaces the selected draft emoji and deselects', () => { + const action = replaceSelectedDraftEmoji('cat'); + const result = reducer( + stateWithOpenCustomizationModalAndSelectedEmoji, + action + ); + + assert.deepStrictEqual( + result.customizePreferredReactionsModal?.draftPreferredReactions, + ['sparkles', 'cat', 'sparkler', 'shark', 'sparkling_heart', 'parking'] + ); + assert.isUndefined( + result.customizePreferredReactionsModal?.selectedDraftEmojiIndex + ); + }); + }); + + describe('resetDraftEmoji', () => { + const { resetDraftEmoji } = actions; + + it('is a no-op if the customization modal is not open', () => { + const state = getInitialState(); + const action = resetDraftEmoji(); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it('resets the draft emoji to the defaults', () => { + const action = resetDraftEmoji(); + const result = reducer(stateWithOpenCustomizationModal, action); + + assert.deepEqual( + result.customizePreferredReactionsModal?.draftPreferredReactions, + DEFAULT_PREFERRED_REACTION_EMOJI + ); + }); + + it('deselects any selected emoji', () => { + const action = resetDraftEmoji(); + const result = reducer( + stateWithOpenCustomizationModalAndSelectedEmoji, + action + ); + + assert.isUndefined( + result.customizePreferredReactionsModal?.selectedDraftEmojiIndex + ); + }); + }); + + describe('savePreferredReactions', () => { + const { savePreferredReactions } = actions; + + let storagePutStub: sinon.SinonStub; + beforeEach(() => { + storagePutStub = sinonSandbox.stub(window.storage, 'put').resolves(); + }); + + describe('thunk', () => { + it('saves the preferred reaction emoji to storage', async () => { + await savePreferredReactions()( + sinon.spy(), + () => getRootState(stateWithOpenCustomizationModal), + null + ); + + sinon.assert.calledWith( + storagePutStub, + 'preferredReactionEmoji', + stateWithOpenCustomizationModal.customizePreferredReactionsModal + .draftPreferredReactions + ); + }); + + it('on success, dispatches a pending action followed by a fulfilled action', async () => { + const dispatch = sinon.spy(); + await savePreferredReactions()( + dispatch, + () => getRootState(stateWithOpenCustomizationModal), + null + ); + + sinon.assert.calledTwice(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'preferredReactions/SAVE_PREFERRED_REACTIONS_PENDING', + }); + sinon.assert.calledWith(dispatch, { + type: 'preferredReactions/SAVE_PREFERRED_REACTIONS_FULFILLED', + }); + }); + + it('on failure, dispatches a pending action followed by a rejected action', async () => { + storagePutStub.rejects(new Error('something went wrong')); + + const dispatch = sinon.spy(); + await savePreferredReactions()( + dispatch, + () => getRootState(stateWithOpenCustomizationModal), + null + ); + + sinon.assert.calledTwice(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'preferredReactions/SAVE_PREFERRED_REACTIONS_PENDING', + }); + sinon.assert.calledWith(dispatch, { + type: 'preferredReactions/SAVE_PREFERRED_REACTIONS_REJECTED', + }); + }); + }); + + describe('SAVE_PREFERRED_REACTIONS_FULFILLED', () => { + const action = { + type: 'preferredReactions/SAVE_PREFERRED_REACTIONS_FULFILLED' as const, + }; + + it("does nothing if the modal isn't open", () => { + const result = reducer(getInitialState(), action); + + assert.notProperty(result, 'customizePreferredReactionsModal'); + }); + + it('closes the modal if open', () => { + const result = reducer(stateWithOpenCustomizationModal, action); + + assert.notProperty(result, 'customizePreferredReactionsModal'); + }); + }); + + describe('SAVE_PREFERRED_REACTIONS_PENDING', () => { + const action = { + type: 'preferredReactions/SAVE_PREFERRED_REACTIONS_PENDING' as const, + }; + + it('marks the modal as "saving"', () => { + const result = reducer(stateWithOpenCustomizationModal, action); + + assert.isTrue(result.customizePreferredReactionsModal?.isSaving); + }); + + it('clears any previous errors', () => { + const state = { + ...stateWithOpenCustomizationModal, + customizePreferredReactionsModal: { + ...stateWithOpenCustomizationModal.customizePreferredReactionsModal, + hadSaveError: true, + }, + }; + const result = reducer(state, action); + + assert.isFalse(result.customizePreferredReactionsModal?.hadSaveError); + }); + + it('deselects any selected emoji', () => { + const result = reducer( + stateWithOpenCustomizationModalAndSelectedEmoji, + action + ); + + assert.isUndefined( + result.customizePreferredReactionsModal?.selectedDraftEmojiIndex + ); + }); + }); + + describe('SAVE_PREFERRED_REACTIONS_REJECTED', () => { + const action = { + type: 'preferredReactions/SAVE_PREFERRED_REACTIONS_REJECTED' as const, + }; + + it("does nothing if the modal isn't open", () => { + const state = getInitialState(); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it('stops loading', () => { + const result = reducer(stateWithOpenCustomizationModal, action); + + assert.isFalse(result.customizePreferredReactionsModal?.isSaving); + }); + + it('saves that there was an error', () => { + const result = reducer(stateWithOpenCustomizationModal, action); + + assert.isTrue(result.customizePreferredReactionsModal?.hadSaveError); + }); + }); + }); + + describe('selectDraftEmojiToBeReplaced', () => { + const { selectDraftEmojiToBeReplaced } = actions; + + it('is a no-op if the customization modal is not open', () => { + const state = getInitialState(); + const action = selectDraftEmojiToBeReplaced(2); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it('is a no-op if the index is out of range', () => { + const action = selectDraftEmojiToBeReplaced(99); + const result = reducer(stateWithOpenCustomizationModal, action); + + assert.strictEqual(result, stateWithOpenCustomizationModal); + }); + + it('sets the index as the selected one', () => { + const action = selectDraftEmojiToBeReplaced(3); + const result = reducer(stateWithOpenCustomizationModal, action); + + assert.strictEqual( + result.customizePreferredReactionsModal?.selectedDraftEmojiIndex, + 3 + ); + }); + }); +}); diff --git a/ts/test-both/state/selectors/items_test.ts b/ts/test-both/state/selectors/items_test.ts index 59b885630..21d014a97 100644 --- a/ts/test-both/state/selectors/items_test.ts +++ b/ts/test-both/state/selectors/items_test.ts @@ -1,29 +1,62 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; -import { getPinnedConversationIds } from '../../../state/selectors/items'; +import { + getEmojiSkinTone, + getPinnedConversationIds, + getPreferredReactionEmoji, +} from '../../../state/selectors/items'; import type { StateType } from '../../../state/reducer'; +import type { ItemsStateType } from '../../../state/ducks/items'; +import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../../../reactions/constants'; describe('both/state/selectors/items', () => { - describe('#getPinnedConversationIds', () => { - // Note: we would like to use the full reducer here, to get a real empty state object - // but we cannot load the full reducer inside of electron-mocha. - function getDefaultStateType(): StateType { - return { - items: {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - } + // Note: we would like to use the full reducer here, to get a real empty state object + // but we cannot load the full reducer inside of electron-mocha. + function getRootState(items: ItemsStateType): StateType { + return { + items, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + describe('#getEmojiSkinTone', () => { + it('returns 0 if passed anything invalid', () => { + [ + // Invalid types + undefined, + null, + '2', + [2], + // Numbers out of range + -1, + 6, + Infinity, + // Invalid numbers + 0.1, + 1.2, + NaN, + ].forEach(skinTone => { + const state = getRootState({ skinTone }); + assert.strictEqual(getEmojiSkinTone(state), 0); + }); + }); + + it('returns all valid skin tones', () => { + [0, 1, 2, 3, 4, 5].forEach(skinTone => { + const state = getRootState({ skinTone }); + assert.strictEqual(getEmojiSkinTone(state), skinTone); + }); + }); + }); + + describe('#getPinnedConversationIds', () => { it('returns pinnedConversationIds key from items', () => { const expected = ['one', 'two']; - const state: StateType = { - ...getDefaultStateType(), - items: { - pinnedConversationIds: expected, - }, - }; + const state: StateType = getRootState({ + pinnedConversationIds: expected, + }); const actual = getPinnedConversationIds(state); assert.deepEqual(actual, expected); @@ -31,10 +64,43 @@ describe('both/state/selectors/items', () => { it('returns empty array if no saved data', () => { const expected: Array = []; - const state = getDefaultStateType(); + const state = getRootState({}); const actual = getPinnedConversationIds(state); assert.deepEqual(actual, expected); }); }); + + describe('#getPreferredReactionEmoji', () => { + // See also: the tests for the `getPreferredReactionEmoji` helper. + + it('returns the default set if no value is stored', () => { + const state = getRootState({}); + const actual = getPreferredReactionEmoji(state); + + assert.deepStrictEqual(actual, DEFAULT_PREFERRED_REACTION_EMOJI); + }); + + it('returns the default set if the stored value is invalid', () => { + const state = getRootState({ preferredReactionEmoji: ['garbage!!'] }); + const actual = getPreferredReactionEmoji(state); + + assert.deepStrictEqual(actual, DEFAULT_PREFERRED_REACTION_EMOJI); + }); + + it('returns a custom set of emoji', () => { + const preferredReactionEmoji = [ + 'sparkles', + 'sparkle', + 'sparkler', + 'shark', + 'sparkling_heart', + 'parking', + ]; + const state = getRootState({ preferredReactionEmoji }); + const actual = getPreferredReactionEmoji(state); + + assert.deepStrictEqual(actual, preferredReactionEmoji); + }); + }); }); diff --git a/ts/test-both/state/selectors/preferredReactions_test.ts b/ts/test-both/state/selectors/preferredReactions_test.ts new file mode 100644 index 000000000..62a99b5d4 --- /dev/null +++ b/ts/test-both/state/selectors/preferredReactions_test.ts @@ -0,0 +1,56 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { reducer as rootReducer } from '../../../state/reducer'; +import { noopAction } from '../../../state/ducks/noop'; +import type { StateType } from '../../../state/reducer'; +import type { PreferredReactionsStateType } from '../../../state/ducks/preferredReactions'; + +import { getIsCustomizingPreferredReactions } from '../../../state/selectors/preferredReactions'; + +describe('both/state/selectors/preferredReactions', () => { + const getEmptyRootState = (): StateType => + rootReducer(undefined, noopAction()); + + const getRootState = (preferredReactions: PreferredReactionsStateType) => ({ + ...getEmptyRootState(), + preferredReactions, + }); + + describe('getIsCustomizingPreferredReactions', () => { + it('returns false if the modal is closed', () => { + assert.isFalse(getIsCustomizingPreferredReactions(getEmptyRootState())); + }); + + it('returns true if the modal is open', () => { + assert.isTrue( + getIsCustomizingPreferredReactions( + getRootState({ + customizePreferredReactionsModal: { + draftPreferredReactions: [ + 'sparkles', + 'sparkle', + 'sparkler', + 'shark', + 'sparkling_heart', + 'parking', + ], + originalPreferredReactions: [ + 'blue_heart', + 'thumbsup', + 'thumbsdown', + 'joy', + 'open_mouth', + 'cry', + ], + selectedDraftEmojiIndex: undefined, + isSaving: false as const, + hadSaveError: false, + }, + }) + ) + ); + }); + }); +}); diff --git a/ts/test-both/util/replaceIndex_test.ts b/ts/test-both/util/replaceIndex_test.ts new file mode 100644 index 000000000..8544a133c --- /dev/null +++ b/ts/test-both/util/replaceIndex_test.ts @@ -0,0 +1,32 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { replaceIndex } from '../../util/replaceIndex'; + +describe('replaceIndex', () => { + it('returns a new array with an index replaced', () => { + const original = ['a', 'b', 'c', 'd']; + const replaced = replaceIndex(original, 2, 'X'); + + assert.deepStrictEqual(replaced, ['a', 'b', 'X', 'd']); + }); + + it("doesn't modify the original array", () => { + const original = ['a', 'b', 'c', 'd']; + replaceIndex(original, 2, 'X'); + + assert.deepStrictEqual(original, ['a', 'b', 'c', 'd']); + }); + + it('throws if the index is out of range', () => { + const original = ['a', 'b', 'c']; + + [-1, 1.2, 4, Infinity, NaN].forEach(index => { + assert.throws(() => { + replaceIndex(original, index, 'X'); + }); + }); + }); +}); diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index d5505feca..7dcc37ee5 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -110,6 +110,7 @@ export type StorageAccessType = { unidentifiedDeliveryIndicators: boolean; groupCredentials: Array; lastReceivedAtCounter: number; + preferredReactionEmoji: Array; skinTone: number; unreadCount: number; 'challenge:retry-message-ids': ReadonlyArray<{ diff --git a/ts/types/StorageUIKeys.ts b/ts/types/StorageUIKeys.ts index ddf34ada9..0e7dad3d6 100644 --- a/ts/types/StorageUIKeys.ts +++ b/ts/types/StorageUIKeys.ts @@ -25,6 +25,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray = [ 'preferred-video-input-device', 'preferred-audio-input-device', 'preferred-audio-output-device', + 'preferredReactionEmoji', 'skinTone', 'zoomFactor', ]; diff --git a/ts/util/replaceIndex.ts b/ts/util/replaceIndex.ts new file mode 100644 index 000000000..832a89915 --- /dev/null +++ b/ts/util/replaceIndex.ts @@ -0,0 +1,16 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function replaceIndex( + arr: ReadonlyArray, + index: number, + newItem: T +): Array { + if (!(index in arr)) { + throw new RangeError(`replaceIndex: ${index} out of range`); + } + + const result = [...arr]; + result[index] = newItem; + return result; +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 690aeaf42..d4c69765b 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -39,6 +39,7 @@ import { import { MessageModel } from '../models/messages'; import { strictAssert } from '../util/assert'; import { maybeParseUrl } from '../util/url'; +import { replaceIndex } from '../util/replaceIndex'; import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob'; import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue'; import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; @@ -1925,10 +1926,8 @@ export class ConversationView extends window.Backbone.View { draftAttachments: [...draftAttachments, onDisk], }); } else { - const toUpdate = [...draftAttachments]; - toUpdate.splice(index, 1, onDisk); this.model.set({ - draftAttachments: toUpdate, + draftAttachments: replaceIndex(draftAttachments, index, onDisk), }); } this.updateAttachmentsView();