Let users customize the preferred reaction palette

This commit is contained in:
Evan Hahn 2021-09-09 11:29:01 -05:00 committed by GitHub
parent 7a5385e00a
commit f28456c160
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1788 additions and 124 deletions

View file

@ -1244,7 +1244,11 @@
}, },
"save": { "save": {
"message": "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": { "fileIconAlt": {
"message": "File icon", "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": { "WhatsNew__modal-title": {
"message": "What's New", "message": "What's New",
"description": "Title for the whats new modal" "description": "Title for the whats new modal"

View file

@ -7439,6 +7439,19 @@ button.module-image__border-overlay:focus {
&__footer { &__footer {
@extend %module-emoji-picker--ribbon; @extend %module-emoji-picker--ribbon;
justify-content: center; 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 { &__button {
@ -7457,8 +7470,43 @@ button.module-image__border-overlay:focus {
} }
&--footer { &--footer {
&:not(:first-of-type) { &:not(:last-of-type) {
margin-left: 4px; 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
);
}
} }
} }

View file

@ -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;
}
}
}

View file

@ -9,6 +9,8 @@
$emoji-size-from-component: 48px; $emoji-size-from-component: 48px;
$big-emoji-size: 42px; $big-emoji-size: 42px;
$root-selector: &;
@include rounded-corners; @include rounded-corners;
align-items: center; align-items: center;
border-style: solid; border-style: solid;
@ -19,7 +21,6 @@
padding: 3px 7px; padding: 3px 7px;
position: relative; position: relative;
user-select: none; user-select: none;
z-index: 2;
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
animation: { animation: {
@ -48,31 +49,14 @@
justify-content: center; justify-content: center;
position: relative; 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 {
$emoji-button-selector: &; $emoji-button-selector: &;
height: $button-size; height: $button-size;
width: $button-size; width: $button-size;
@media (prefers-reduced-motion: no-preference) {
transition: background 200ms $ease-out-expo;
}
.module-emoji { .module-emoji {
transform: scale($button-content-size / $emoji-size-from-component); transform: scale($button-content-size / $emoji-size-from-component);
@ -80,25 +64,6 @@
transition: transform 400ms $ease-out-expo; 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 { &--more {
@ -117,6 +82,11 @@
&:hover { &:hover {
background: $color-gray-05; background: $color-gray-05;
} }
@include keyboard-mode {
&:focus {
background: $color-gray-05;
}
}
} }
@include dark-theme { @include dark-theme {
@ -125,6 +95,11 @@
&:hover { &:hover {
background: $color-gray-45; background: $color-gray-45;
} }
@include dark-keyboard-mode {
&:focus {
background: $color-gray-45;
}
}
} }
&__dot { &__dot {
@ -146,14 +121,101 @@
} }
} }
} }
}
&--selected { &--picker-style {
@include light-theme { z-index: 2;
background: $color-black-alpha-20;
#{$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 { @for $i from 0 through $max-expected-buttons {
background: $color-white-alpha-20; &: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; opacity: 1;
} }
} }
@keyframes module-ReactionPicker__button-selected {
from {
transform: rotate(-8deg);
}
to {
transform: rotate(8deg);
}
}

View file

@ -51,6 +51,7 @@
@import './components/ContactSpoofingReviewDialogPerson.scss'; @import './components/ContactSpoofingReviewDialogPerson.scss';
@import './components/ConversationHeader.scss'; @import './components/ConversationHeader.scss';
@import './components/CustomColorEditor.scss'; @import './components/CustomColorEditor.scss';
@import './components/CustomizingPreferredReactionsModal.scss';
@import './components/DisappearingTimeDialog.scss'; @import './components/DisappearingTimeDialog.scss';
@import './components/DisappearingTimerSelect.scss'; @import './components/DisappearingTimerSelect.scss';
@import './components/EditConversationAttributesModal.scss'; @import './components/EditConversationAttributesModal.scss';

View file

@ -89,6 +89,7 @@ import {
SendStatus, SendStatus,
} from './messages/MessageSendState'; } from './messages/MessageSendState';
import * as AttachmentDownloads from './messageModifiers/AttachmentDownloads'; import * as AttachmentDownloads from './messageModifiers/AttachmentDownloads';
import * as preferredReactions from './state/ducks/preferredReactions';
import * as Stickers from './types/Stickers'; import * as Stickers from './types/Stickers';
import { SignalService as Proto } from './protobuf'; import { SignalService as Proto } from './protobuf';
import { onRetryRequest, onDecryptionError } from './util/handleRetry'; import { onRetryRequest, onDecryptionError } from './util/handleRetry';
@ -953,6 +954,7 @@ export async function startApp(): Promise<void> {
}, },
emojis: window.Signal.Emojis.getInitialState(), emojis: window.Signal.Emojis.getInitialState(),
items: window.storage.getItemsState(), items: window.storage.getItemsState(),
preferredReactions: preferredReactions.getInitialState(),
stickers: Stickers.getInitialState(), stickers: Stickers.getInitialState(),
user: { user: {
attachmentsPath: window.baseAttachmentsPath, attachmentsPath: window.baseAttachmentsPath,

View file

@ -24,8 +24,10 @@ export const App = ({
conversationsStoppingMessageSendBecauseOfVerification, conversationsStoppingMessageSendBecauseOfVerification,
hasInitialLoadCompleted, hasInitialLoadCompleted,
i18n, i18n,
isCustomizingPreferredReactions,
numberOfMessagesPendingBecauseOfVerification, numberOfMessagesPendingBecauseOfVerification,
renderCallManager, renderCallManager,
renderCustomizingPreferredReactionsModal,
renderGlobalModalContainer, renderGlobalModalContainer,
renderSafetyNumber, renderSafetyNumber,
theme, theme,
@ -48,9 +50,13 @@ export const App = ({
} }
hasInitialLoadCompleted={hasInitialLoadCompleted} hasInitialLoadCompleted={hasInitialLoadCompleted}
i18n={i18n} i18n={i18n}
isCustomizingPreferredReactions={isCustomizingPreferredReactions}
numberOfMessagesPendingBecauseOfVerification={ numberOfMessagesPendingBecauseOfVerification={
numberOfMessagesPendingBecauseOfVerification numberOfMessagesPendingBecauseOfVerification
} }
renderCustomizingPreferredReactionsModal={
renderCustomizingPreferredReactionsModal
}
renderSafetyNumber={renderSafetyNumber} renderSafetyNumber={renderSafetyNumber}
verifyConversationsStoppingMessageSend={ verifyConversationsStoppingMessageSend={
verifyConversationsStoppingMessageSend verifyConversationsStoppingMessageSend

View file

@ -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', () => (
<CustomizingPreferredReactionsModal {...defaultProps} />
));
story.add('Draft emoji selected', () => (
<CustomizingPreferredReactionsModal
{...defaultProps}
selectedDraftEmojiIndex={4}
/>
));
story.add('Saving', () => (
<CustomizingPreferredReactionsModal {...defaultProps} isSaving />
));
story.add('Had error', () => (
<CustomizingPreferredReactionsModal {...defaultProps} hadSaveError />
));

View file

@ -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<string>;
hadSaveError: boolean;
i18n: LocalizerType;
isSaving: boolean;
originalPreferredReactions: Array<string>;
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<PropsType>): JSX.Element {
const [
referenceElement,
setReferenceElement,
] = useState<null | HTMLDivElement>(null);
const [popperElement, setPopperElement] = useState<null | HTMLDivElement>(
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 (
<Modal
hasXButton
i18n={i18n}
onClose={() => {
cancelCustomizePreferredReactionsModal();
}}
title={i18n('CustomizingPreferredReactions__title')}
>
<div className="module-CustomizingPreferredReactionsModal__reaction-picker-wrapper">
<ReactionPicker
hasMoreButton={false}
i18n={i18n}
onPick={onPick}
ref={setReferenceElement}
preferredReactionEmoji={draftPreferredReactions}
selected={selected}
selectionStyle={ReactionPickerSelectionStyle.Menu}
renderEmojiPicker={shouldNotBeCalled}
skinTone={skinTone}
/>
{hadSaveError
? i18n('CustomizingPreferredReactions__had-save-error')
: i18n('CustomizingPreferredReactions__subtitle')}
</div>
{isSomethingSelected && (
<div
className="module-CustomizingPreferredReactionsModal__emoji-picker-wrapper"
ref={setPopperElement}
style={emojiPickerPopper.styles.popper}
{...emojiPickerPopper.attributes.popper}
>
<EmojiPicker
i18n={i18n}
onPickEmoji={({ shortName }) => {
replaceSelectedDraftEmoji(shortName);
}}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
onClose={() => {
deselectDraftEmoji();
}}
/>
</div>
)}
<Modal.ButtonFooter>
<Button
disabled={!canReset}
onClick={() => {
resetDraftEmoji();
}}
variant={ButtonVariant.SecondaryAffirmative}
>
{i18n('reset')}
</Button>
<Button
disabled={!canSave}
onClick={() => {
savePreferredReactions();
}}
>
{i18n('save')}
</Button>
</Modal.ButtonFooter>
</Modal>
);
}
function shouldNotBeCalled(): React.ReactElement {
throw new Error('This should not be called');
}

View file

@ -24,7 +24,9 @@ export type PropsType = {
conversationsStoppingMessageSendBecauseOfVerification: Array<ConversationType>; conversationsStoppingMessageSendBecauseOfVerification: Array<ConversationType>;
hasInitialLoadCompleted: boolean; hasInitialLoadCompleted: boolean;
i18n: LocalizerType; i18n: LocalizerType;
isCustomizingPreferredReactions: boolean;
numberOfMessagesPendingBecauseOfVerification: number; numberOfMessagesPendingBecauseOfVerification: number;
renderCustomizingPreferredReactionsModal: () => JSX.Element;
renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element; renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element;
verifyConversationsStoppingMessageSend: () => void; verifyConversationsStoppingMessageSend: () => void;
}; };
@ -34,7 +36,9 @@ export const Inbox = ({
conversationsStoppingMessageSendBecauseOfVerification, conversationsStoppingMessageSendBecauseOfVerification,
hasInitialLoadCompleted, hasInitialLoadCompleted,
i18n, i18n,
isCustomizingPreferredReactions,
numberOfMessagesPendingBecauseOfVerification, numberOfMessagesPendingBecauseOfVerification,
renderCustomizingPreferredReactionsModal,
renderSafetyNumber, renderSafetyNumber,
verifyConversationsStoppingMessageSend, verifyConversationsStoppingMessageSend,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
@ -67,7 +71,7 @@ export const Inbox = ({
} }
}, [hasInitialLoadCompleted, viewRef]); }, [hasInitialLoadCompleted, viewRef]);
let safetyNumberChangeDialog: ReactNode; let activeModal: ReactNode;
if (conversationsStoppingMessageSendBecauseOfVerification.length) { if (conversationsStoppingMessageSendBecauseOfVerification.length) {
const confirmText: string = const confirmText: string =
numberOfMessagesPendingBecauseOfVerification === 1 numberOfMessagesPendingBecauseOfVerification === 1
@ -75,7 +79,7 @@ export const Inbox = ({
: i18n('safetyNumberChangeDialog__pending-messages--many', [ : i18n('safetyNumberChangeDialog__pending-messages--many', [
numberOfMessagesPendingBecauseOfVerification.toString(), numberOfMessagesPendingBecauseOfVerification.toString(),
]); ]);
safetyNumberChangeDialog = ( activeModal = (
<SafetyNumberChangeDialog <SafetyNumberChangeDialog
confirmText={confirmText} confirmText={confirmText}
contacts={conversationsStoppingMessageSendBecauseOfVerification} contacts={conversationsStoppingMessageSendBecauseOfVerification}
@ -86,11 +90,14 @@ export const Inbox = ({
/> />
); );
} }
if (!activeModal && isCustomizingPreferredReactions) {
activeModal = renderCustomizingPreferredReactionsModal();
}
return ( return (
<> <>
<div className="inbox index" ref={hostRef} /> <div className="inbox index" ref={hostRef} />
{safetyNumberChangeDialog} {activeModal}
</> </>
); );
}; };

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
@ -9,11 +9,24 @@ import { action } from '@storybook/addon-actions';
import { select } from '@storybook/addon-knobs'; import { select } from '@storybook/addon-knobs';
import { setup as setupI18n } from '../../../js/modules/i18n'; import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json'; 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'; import { EmojiPicker } from '../emoji/EmojiPicker';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
const preferredReactionEmoji = [
'heart',
'thumbsup',
'thumbsdown',
'joy',
'open_mouth',
'cry',
];
const renderEmojiPicker: ReactionPickerProps['renderEmojiPicker'] = ({ const renderEmojiPicker: ReactionPickerProps['renderEmojiPicker'] = ({
onClose, onClose,
onPickEmoji, onPickEmoji,
@ -35,7 +48,12 @@ storiesOf('Components/Conversation/ReactionPicker', module)
<ReactionPicker <ReactionPicker
i18n={i18n} i18n={i18n}
onPick={action('onPick')} onPick={action('onPick')}
openCustomizePreferredReactionsModal={action(
'openCustomizePreferredReactionsModal'
)}
preferredReactionEmoji={preferredReactionEmoji}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
selectionStyle={ReactionPickerSelectionStyle.Picker}
skinTone={0} skinTone={0}
/> />
); );
@ -47,7 +65,12 @@ storiesOf('Components/Conversation/ReactionPicker', module)
i18n={i18n} i18n={i18n}
selected={e} selected={e}
onPick={action('onPick')} onPick={action('onPick')}
openCustomizePreferredReactionsModal={action(
'openCustomizePreferredReactionsModal'
)}
preferredReactionEmoji={preferredReactionEmoji}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
selectionStyle={ReactionPickerSelectionStyle.Picker}
skinTone={0} skinTone={0}
/> />
</div> </div>
@ -60,7 +83,12 @@ storiesOf('Components/Conversation/ReactionPicker', module)
i18n={i18n} i18n={i18n}
selected={e} selected={e}
onPick={action('onPick')} onPick={action('onPick')}
openCustomizePreferredReactionsModal={action(
'openCustomizePreferredReactionsModal'
)}
preferredReactionEmoji={preferredReactionEmoji}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
selectionStyle={ReactionPickerSelectionStyle.Picker}
skinTone={select( skinTone={select(
'skinTone', 'skinTone',
{ 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 }, { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 },

View file

@ -1,39 +1,41 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import * as log from '../../logging/log';
import { Emoji } from '../emoji/Emoji'; import { Emoji } from '../emoji/Emoji';
import { convertShortName } from '../emoji/lib'; import { convertShortName } from '../emoji/lib';
import { Props as EmojiPickerProps } from '../emoji/EmojiPicker'; import { Props as EmojiPickerProps } from '../emoji/EmojiPicker';
import { missingCaseError } from '../../util/missingCaseError';
import { useRestoreFocus } from '../../util/hooks/useRestoreFocus'; import { useRestoreFocus } from '../../util/hooks/useRestoreFocus';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
export enum ReactionPickerSelectionStyle {
Picker,
Menu,
}
export type RenderEmojiPickerProps = Pick<Props, 'onClose' | 'style'> & export type RenderEmojiPickerProps = Pick<Props, 'onClose' | 'style'> &
Pick<EmojiPickerProps, 'onPickEmoji'> & { Pick<EmojiPickerProps, 'onClickSettings' | 'onPickEmoji'> & {
ref: React.Ref<HTMLDivElement>; ref: React.Ref<HTMLDivElement>;
}; };
export type OwnProps = { export type OwnProps = {
hasMoreButton?: boolean;
i18n: LocalizerType; i18n: LocalizerType;
selected?: string; selected?: string;
selectionStyle: ReactionPickerSelectionStyle;
onClose?: () => unknown; onClose?: () => unknown;
onPick: (emoji: string) => unknown; onPick: (emoji: string) => unknown;
openCustomizePreferredReactionsModal?: () => unknown;
preferredReactionEmoji: Array<string>;
renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement; renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement;
skinTone: number; skinTone: number;
}; };
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>; export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
const DEFAULT_EMOJI_LIST = [
'heart',
'thumbsup',
'thumbsdown',
'joy',
'open_mouth',
'cry',
];
const EmojiButton = React.forwardRef< const EmojiButton = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
{ {
@ -64,7 +66,19 @@ const EmojiButton = React.forwardRef<
export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>( export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
( (
{ i18n, selected, onClose, skinTone, onPick, renderEmojiPicker, style }, {
hasMoreButton = true,
i18n,
onClose,
onPick,
openCustomizePreferredReactionsModal,
preferredReactionEmoji,
renderEmojiPicker,
selected,
selectionStyle,
skinTone,
style,
},
ref ref
) => { ) => {
const [pickingOther, setPickingOther] = React.useState(false); const [pickingOther, setPickingOther] = React.useState(false);
@ -96,17 +110,25 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
const [focusRef] = useRestoreFocus(); const [focusRef] = useRestoreFocus();
if (pickingOther) { 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) convertShortName(shortName, skinTone)
); );
const otherSelected = selected && !emojis.includes(selected); const otherSelected = selected && !emojis.includes(selected);
let moreButton: React.ReactNode; let moreButton: React.ReactNode;
if (otherSelected) { if (!hasMoreButton) {
moreButton = undefined;
} else if (otherSelected) {
moreButton = ( moreButton = (
<EmojiButton <EmojiButton
emoji={selected} emoji={selected}
@ -137,8 +159,30 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
); );
} }
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 ( return (
<div ref={ref} style={style} className="module-ReactionPicker"> <div
ref={ref}
style={style}
className={classNames(
'module-ReactionPicker',
selectionStyleClassName,
selected ? 'module-ReactionPicker--something-selected' : undefined
)}
>
{emojis.map((emoji, index) => { {emojis.map((emoji, index) => {
const maybeFocusRef = index === 0 ? focusRef : undefined; const maybeFocusRef = index === 0 ? focusRef : undefined;

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
@ -70,4 +70,17 @@ storiesOf('Components/Emoji/EmojiPicker', module)
recentEmojis={[]} recentEmojis={[]}
/> />
); );
})
.add('With settings button', () => {
return (
<EmojiPicker
i18n={i18n}
onPickEmoji={action('onPickEmoji')}
onSetSkinTone={action('onSetSkinTone')}
onClickSettings={action('onClickSettings')}
onClose={action('onClose')}
skinTone={0}
recentEmojis={[]}
/>
);
}); });

View file

@ -1,4 +1,4 @@
// Copyright 2019-2020 Signal Messenger, LLC // Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
@ -34,6 +34,7 @@ export type OwnProps = {
readonly skinTone?: number; readonly skinTone?: number;
readonly onSetSkinTone?: (tone: number) => unknown; readonly onSetSkinTone?: (tone: number) => unknown;
readonly recentEmojis?: Array<string>; readonly recentEmojis?: Array<string>;
readonly onClickSettings?: () => unknown;
readonly onClose?: () => unknown; readonly onClose?: () => unknown;
}; };
@ -70,6 +71,7 @@ export const EmojiPicker = React.memo(
onSetSkinTone, onSetSkinTone,
recentEmojis = [], recentEmojis = [],
style, style,
onClickSettings,
onClose, onClose,
}: Props, }: Props,
ref ref
@ -383,24 +385,38 @@ export const EmojiPicker = React.memo(
</div> </div>
)} )}
<footer className="module-emoji-picker__footer"> <footer className="module-emoji-picker__footer">
{[0, 1, 2, 3, 4, 5].map(tone => ( {Boolean(onClickSettings) && (
<button <button
aria-label={i18n('CustomizingPreferredReactions__title')}
className="module-emoji-picker__button module-emoji-picker__button--footer module-emoji-picker__button--settings"
onClick={onClickSettings}
title={i18n('CustomizingPreferredReactions__title')}
type="button" type="button"
key={tone} />
data-tone={tone} )}
onClick={handlePickTone} <div className="module-emoji-picker__footer__skin-tones">
title={i18n('EmojiPicker--skin-tone', [`${tone}`])} {[0, 1, 2, 3, 4, 5].map(tone => (
className={classNames( <button
'module-emoji-picker__button', type="button"
'module-emoji-picker__button--footer', key={tone}
selectedTone === tone data-tone={tone}
? 'module-emoji-picker__button--selected' onClick={handlePickTone}
: null title={i18n('EmojiPicker--skin-tone', [`${tone}`])}
)} className={classNames(
> 'module-emoji-picker__button',
<Emoji shortName="hand" skinTone={tone} size={20} /> 'module-emoji-picker__button--footer',
</button> selectedTone === tone
))} ? 'module-emoji-picker__button--selected'
: null
)}
>
<Emoji shortName="hand" skinTone={tone} size={20} />
</button>
))}
</div>
{Boolean(onClickSettings) && (
<div className="module-emoji-picker__footer__settings-spacer" />
)}
</footer> </footer>
</div> </div>
); );

11
ts/reactions/constants.ts Normal file
View file

@ -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',
];

View file

@ -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<string> {
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<unknown>): boolean {
return new Set(arr).size !== arr.length;
}

View file

@ -29,6 +29,8 @@ export type ItemsStateType = {
readonly defaultConversationColor?: DefaultConversationColorType; readonly defaultConversationColor?: DefaultConversationColorType;
readonly customColors?: CustomColorsItemType; readonly customColors?: CustomColorsItemType;
readonly preferredReactionEmoji?: Array<string>;
}; };
// Actions // Actions

View file

@ -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<string>;
originalPreferredReactions: Array<string>;
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<string>;
};
};
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<PreferredReactionsStateType> = 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;
}
}

View file

@ -1,4 +1,4 @@
// Copyright 2019-2020 Signal Messenger, LLC // Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { combineReducers } from 'redux'; 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 items } from './ducks/items';
import { reducer as linkPreviews } from './ducks/linkPreviews'; import { reducer as linkPreviews } from './ducks/linkPreviews';
import { reducer as network } from './ducks/network'; import { reducer as network } from './ducks/network';
import { reducer as preferredReactions } from './ducks/preferredReactions';
import { reducer as safetyNumber } from './ducks/safetyNumber'; import { reducer as safetyNumber } from './ducks/safetyNumber';
import { reducer as search } from './ducks/search'; import { reducer as search } from './ducks/search';
import { reducer as stickers } from './ducks/stickers'; import { reducer as stickers } from './ducks/stickers';
@ -34,6 +35,7 @@ export const reducer = combineReducers({
items, items,
linkPreviews, linkPreviews,
network, network,
preferredReactions,
safetyNumber, safetyNumber,
search, search,
stickers, stickers,

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { isInteger } from 'lodash';
import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer'; import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer';
@ -12,6 +13,7 @@ import {
CustomColorType, CustomColorType,
DEFAULT_CONVERSATION_COLOR, DEFAULT_CONVERSATION_COLOR,
} from '../../types/Colors'; } from '../../types/Colors';
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/getPreferredReactionEmoji';
export const getItems = (state: StateType): ItemsStateType => state.items; export const getItems = (state: StateType): ItemsStateType => state.items;
@ -49,3 +51,20 @@ export const getCustomColors = createSelector(
(state: ItemsStateType): Record<string, CustomColorType> | undefined => (state: ItemsStateType): Record<string, CustomColorType> | undefined =>
state.customColors?.colors state.customColors?.colors
); );
export const getEmojiSkinTone = createSelector(
getItems,
({ skinTone }: Readonly<ItemsStateType>): number =>
typeof skinTone === 'number' &&
isInteger(skinTone) &&
skinTone >= 0 &&
skinTone <= 5
? skinTone
: 0
);
export const getPreferredReactionEmoji = createSelector(
getItems,
(state: Readonly<ItemsStateType>): Array<string> =>
getPreferredReactionEmojiFromStoredValue(state.preferredReactionEmoji)
);

View file

@ -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<StateType>
): PreferredReactionsStateType => state.preferredReactions;
export const getCustomizeModalState = createSelector(
getPreferredReactionsState,
(state: Readonly<PreferredReactionsStateType>) =>
state.customizePreferredReactionsModal
);
export const getIsCustomizingPreferredReactions = createSelector(
getCustomizeModalState,
(customizeModal): boolean => Boolean(customizeModal)
);

View file

@ -6,6 +6,7 @@ import { connect } from 'react-redux';
import { App } from '../../components/App'; import { App } from '../../components/App';
import { SmartCallManager } from './CallManager'; import { SmartCallManager } from './CallManager';
import { SmartCustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal';
import { SmartGlobalModalContainer } from './GlobalModalContainer'; import { SmartGlobalModalContainer } from './GlobalModalContainer';
import { SmartSafetyNumberViewer } from './SafetyNumberViewer'; import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
@ -14,6 +15,7 @@ import {
getConversationsStoppingMessageSendBecauseOfVerification, getConversationsStoppingMessageSendBecauseOfVerification,
getNumberOfMessagesPendingBecauseOfVerification, getNumberOfMessagesPendingBecauseOfVerification,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog'; import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog';
@ -24,10 +26,14 @@ const mapStateToProps = (state: StateType) => {
state state
), ),
i18n: getIntl(state), i18n: getIntl(state),
isCustomizingPreferredReactions: getIsCustomizingPreferredReactions(state),
numberOfMessagesPendingBecauseOfVerification: getNumberOfMessagesPendingBecauseOfVerification( numberOfMessagesPendingBecauseOfVerification: getNumberOfMessagesPendingBecauseOfVerification(
state state
), ),
renderCallManager: () => <SmartCallManager />, renderCallManager: () => <SmartCallManager />,
renderCustomizingPreferredReactionsModal: () => (
<SmartCustomizingPreferredReactionsModal />
),
renderGlobalModalContainer: () => <SmartGlobalModalContainer />, renderGlobalModalContainer: () => <SmartGlobalModalContainer />,
renderSafetyNumber: (props: SafetyNumberProps) => ( renderSafetyNumber: (props: SafetyNumberProps) => (
<SmartSafetyNumberViewer {...props} /> <SmartSafetyNumberViewer {...props} />

View file

@ -10,6 +10,7 @@ import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
import { selectRecentEmojis } from '../selectors/emojis'; import { selectRecentEmojis } from '../selectors/emojis';
import { getIntl, getUserConversationId } from '../selectors/user'; import { getIntl, getUserConversationId } from '../selectors/user';
import { getEmojiSkinTone } from '../selectors/items';
import { import {
getConversationSelector, getConversationSelector,
getGroupAdminsSelector, getGroupAdminsSelector,
@ -100,7 +101,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
}, },
// Emojis // Emojis
recentEmojis, recentEmojis,
skinTone: get(state, ['items', 'skinTone'], 0), skinTone: getEmojiSkinTone(state),
// Stickers // Stickers
receivedPacks, receivedPacks,
installedPack, installedPack,

View file

@ -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<StateType, LocalizerType>(getIntl);
const customizeModalState = useSelector<
StateType,
ReturnType<typeof getCustomizeModalState>
>(state => getCustomizeModalState(state));
if (!customizeModalState) {
throw new Error(
'<SmartCustomizingPreferredReactionsModal> requires a modal'
);
}
const skinTone = useSelector<StateType, number>(state =>
getEmojiSkinTone(state)
);
return (
<CustomizingPreferredReactionsModal
i18n={i18n}
onSetSkinTone={onSetSkinTone}
skinTone={skinTone}
{...preferredReactionsActions}
{...customizeModalState}
/>
);
}

View file

@ -1,9 +1,8 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { get } from 'lodash';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { useRecentEmojis } from '../selectors/emojis'; import { useRecentEmojis } from '../selectors/emojis';
import { useActions as useEmojiActions } from '../ducks/emojis'; import { useActions as useEmojiActions } from '../ducks/emojis';
@ -13,15 +12,19 @@ import {
Props as EmojiPickerProps, Props as EmojiPickerProps,
} from '../../components/emoji/EmojiPicker'; } from '../../components/emoji/EmojiPicker';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getEmojiSkinTone } from '../selectors/items';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
export const SmartEmojiPicker = React.forwardRef< export const SmartEmojiPicker = React.forwardRef<
HTMLDivElement, HTMLDivElement,
Pick<EmojiPickerProps, 'onPickEmoji' | 'onSetSkinTone' | 'onClose' | 'style'> Pick<
>(({ onPickEmoji, onSetSkinTone, onClose, style }, ref) => { EmojiPickerProps,
'onClickSettings' | 'onPickEmoji' | 'onSetSkinTone' | 'onClose' | 'style'
>
>(({ onClickSettings, onPickEmoji, onSetSkinTone, onClose, style }, ref) => {
const i18n = useSelector<StateType, LocalizerType>(getIntl); const i18n = useSelector<StateType, LocalizerType>(getIntl);
const skinTone = useSelector<StateType, number>(state => const skinTone = useSelector<StateType, number>(state =>
get(state, ['items', 'skinTone'], 0) getEmojiSkinTone(state)
); );
const recentEmojis = useRecentEmojis(); const recentEmojis = useRecentEmojis();
@ -41,6 +44,7 @@ export const SmartEmojiPicker = React.forwardRef<
ref={ref} ref={ref}
i18n={i18n} i18n={i18n}
skinTone={skinTone} skinTone={skinTone}
onClickSettings={onClickSettings}
onSetSkinTone={onSetSkinTone} onSetSkinTone={onSetSkinTone}
onPickEmoji={handlePickEmoji} onPickEmoji={handlePickEmoji}
recentEmojis={recentEmojis} recentEmojis={recentEmojis}

View file

@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { get } from 'lodash';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import { import {
ForwardMessageModal, ForwardMessageModal,
@ -14,6 +13,7 @@ import { LinkPreviewType } from '../../types/message/LinkPreviews';
import { getAllComposableConversations } from '../selectors/conversations'; import { getAllComposableConversations } from '../selectors/conversations';
import { getLinkPreview } from '../selectors/linkPreviews'; import { getLinkPreview } from '../selectors/linkPreviews';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getEmojiSkinTone } from '../selectors/items';
import { selectRecentEmojis } from '../selectors/emojis'; import { selectRecentEmojis } from '../selectors/emojis';
import { AttachmentType } from '../../types/Attachment'; import { AttachmentType } from '../../types/Attachment';
@ -52,7 +52,7 @@ const mapStateToProps = (
const candidateConversations = getAllComposableConversations(state); const candidateConversations = getAllComposableConversations(state);
const recentEmojis = selectRecentEmojis(state); const recentEmojis = selectRecentEmojis(state);
const skinTone = get(state, ['items', 'skinTone'], 0); const skinTone = getEmojiSkinTone(state);
const linkPreview = getLinkPreview(state); const linkPreview = getLinkPreview(state);
return { return {

View file

@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { get } from 'lodash';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import { import {
ProfileEditorModal, ProfileEditorModal,
@ -11,6 +10,7 @@ import {
import { PropsDataType } from '../../components/ProfileEditor'; import { PropsDataType } from '../../components/ProfileEditor';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getEmojiSkinTone } from '../selectors/items';
import { getMe } from '../selectors/conversations'; import { getMe } from '../selectors/conversations';
import { selectRecentEmojis } from '../selectors/emojis'; import { selectRecentEmojis } from '../selectors/emojis';
@ -28,7 +28,7 @@ function mapStateToProps(
id: conversationId, id: conversationId,
} = getMe(state); } = getMe(state);
const recentEmojis = selectRecentEmojis(state); const recentEmojis = selectRecentEmojis(state);
const skinTone = get(state, ['items', 'skinTone'], 0); const skinTone = getEmojiSkinTone(state);
return { return {
aboutEmoji, aboutEmoji,

View file

@ -1,32 +1,62 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { get } from 'lodash';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import {
getEmojiSkinTone,
getPreferredReactionEmoji,
} from '../selectors/items';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
import { import {
ReactionPicker, ReactionPicker,
ReactionPickerSelectionStyle,
Props, Props,
} from '../../components/conversation/ReactionPicker'; } from '../../components/conversation/ReactionPicker';
type ExternalProps = Omit<Props, 'skinTone' | 'i18n'>; type ExternalProps = Omit<
Props,
| 'i18n'
| 'openCustomizePreferredReactionsModal'
| 'preferredReactionEmoji'
| 'selectionStyle'
| 'skinTone'
>;
export const SmartReactionPicker = React.forwardRef< export const SmartReactionPicker = React.forwardRef<
HTMLDivElement, HTMLDivElement,
ExternalProps ExternalProps
>((props, ref) => { >((props, ref) => {
const {
openCustomizePreferredReactionsModal,
} = usePreferredReactionsActions();
const i18n = useSelector<StateType, LocalizerType>(getIntl); const i18n = useSelector<StateType, LocalizerType>(getIntl);
const preferredReactionEmoji = useSelector<StateType, Array<string>>(
getPreferredReactionEmoji
);
const skinTone = useSelector<StateType, number>(state => const skinTone = useSelector<StateType, number>(state =>
get(state, ['items', 'skinTone'], 0) getEmojiSkinTone(state)
); );
return ( return (
<ReactionPicker ref={ref} skinTone={skinTone} i18n={i18n} {...props} /> <ReactionPicker
i18n={i18n}
openCustomizePreferredReactionsModal={
openCustomizePreferredReactionsModal
}
preferredReactionEmoji={preferredReactionEmoji}
ref={ref}
selectionStyle={ReactionPickerSelectionStyle.Picker}
skinTone={skinTone}
{...props}
/>
); );
}); });

View file

@ -8,6 +8,7 @@ import { SmartEmojiPicker } from './EmojiPicker';
export function renderEmojiPicker({ export function renderEmojiPicker({
ref, ref,
onClickSettings,
onPickEmoji, onPickEmoji,
onClose, onClose,
style, style,
@ -15,6 +16,7 @@ export function renderEmojiPicker({
return ( return (
<SmartEmojiPicker <SmartEmojiPicker
ref={ref} ref={ref}
onClickSettings={onClickSettings}
onPickEmoji={onPickEmoji} onPickEmoji={onPickEmoji}
onClose={onClose} onClose={onClose}
style={style} style={style}

View file

@ -0,0 +1,46 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../../reactions/constants';
import { getPreferredReactionEmoji } from '../../reactions/getPreferredReactionEmoji';
describe('getPreferredReactionEmoji', () => {
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);
});
});

View file

@ -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
);
});
});
});

View file

@ -1,29 +1,62 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; 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 { 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('both/state/selectors/items', () => {
describe('#getPinnedConversationIds', () => { // Note: we would like to use the full reducer here, to get a real empty state object
// 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.
// but we cannot load the full reducer inside of electron-mocha. function getRootState(items: ItemsStateType): StateType {
function getDefaultStateType(): StateType { return {
return { items,
items: {}, // eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any } as 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', () => { it('returns pinnedConversationIds key from items', () => {
const expected = ['one', 'two']; const expected = ['one', 'two'];
const state: StateType = { const state: StateType = getRootState({
...getDefaultStateType(), pinnedConversationIds: expected,
items: { });
pinnedConversationIds: expected,
},
};
const actual = getPinnedConversationIds(state); const actual = getPinnedConversationIds(state);
assert.deepEqual(actual, expected); assert.deepEqual(actual, expected);
@ -31,10 +64,43 @@ describe('both/state/selectors/items', () => {
it('returns empty array if no saved data', () => { it('returns empty array if no saved data', () => {
const expected: Array<string> = []; const expected: Array<string> = [];
const state = getDefaultStateType(); const state = getRootState({});
const actual = getPinnedConversationIds(state); const actual = getPinnedConversationIds(state);
assert.deepEqual(actual, expected); 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);
});
});
}); });

View file

@ -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,
},
})
)
);
});
});
});

View file

@ -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');
});
});
});
});

View file

@ -110,6 +110,7 @@ export type StorageAccessType = {
unidentifiedDeliveryIndicators: boolean; unidentifiedDeliveryIndicators: boolean;
groupCredentials: Array<GroupCredentialType>; groupCredentials: Array<GroupCredentialType>;
lastReceivedAtCounter: number; lastReceivedAtCounter: number;
preferredReactionEmoji: Array<string>;
skinTone: number; skinTone: number;
unreadCount: number; unreadCount: number;
'challenge:retry-message-ids': ReadonlyArray<{ 'challenge:retry-message-ids': ReadonlyArray<{

View file

@ -25,6 +25,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray<keyof StorageAccessType> = [
'preferred-video-input-device', 'preferred-video-input-device',
'preferred-audio-input-device', 'preferred-audio-input-device',
'preferred-audio-output-device', 'preferred-audio-output-device',
'preferredReactionEmoji',
'skinTone', 'skinTone',
'zoomFactor', 'zoomFactor',
]; ];

16
ts/util/replaceIndex.ts Normal file
View file

@ -0,0 +1,16 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function replaceIndex<T>(
arr: ReadonlyArray<T>,
index: number,
newItem: T
): Array<T> {
if (!(index in arr)) {
throw new RangeError(`replaceIndex: ${index} out of range`);
}
const result = [...arr];
result[index] = newItem;
return result;
}

View file

@ -39,6 +39,7 @@ import {
import { MessageModel } from '../models/messages'; import { MessageModel } from '../models/messages';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { maybeParseUrl } from '../util/url'; import { maybeParseUrl } from '../util/url';
import { replaceIndex } from '../util/replaceIndex';
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob'; import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue'; import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
@ -1925,10 +1926,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
draftAttachments: [...draftAttachments, onDisk], draftAttachments: [...draftAttachments, onDisk],
}); });
} else { } else {
const toUpdate = [...draftAttachments];
toUpdate.splice(index, 1, onDisk);
this.model.set({ this.model.set({
draftAttachments: toUpdate, draftAttachments: replaceIndex(draftAttachments, index, onDisk),
}); });
} }
this.updateAttachmentsView(); this.updateAttachmentsView();