Let users customize the preferred reaction palette
This commit is contained in:
parent
7a5385e00a
commit
f28456c160
38 changed files with 1788 additions and 124 deletions
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
71
ts/components/CustomizingPreferredReactionsModal.stories.tsx
Normal file
71
ts/components/CustomizingPreferredReactionsModal.stories.tsx
Normal 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 />
|
||||||
|
));
|
193
ts/components/CustomizingPreferredReactionsModal.tsx
Normal file
193
ts/components/CustomizingPreferredReactionsModal.tsx
Normal 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');
|
||||||
|
}
|
|
@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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={[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
11
ts/reactions/constants.ts
Normal 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',
|
||||||
|
];
|
20
ts/reactions/getPreferredReactionEmoji.ts
Normal file
20
ts/reactions/getPreferredReactionEmoji.ts
Normal 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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
315
ts/state/ducks/preferredReactions.ts
Normal file
315
ts/state/ducks/preferredReactions.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
);
|
||||||
|
|
22
ts/state/selectors/preferredReactions.ts
Normal file
22
ts/state/selectors/preferredReactions.ts
Normal 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)
|
||||||
|
);
|
|
@ -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} />
|
||||||
|
|
|
@ -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,
|
||||||
|
|
46
ts/state/smart/CustomizingPreferredReactionsModal.tsx
Normal file
46
ts/state/smart/CustomizingPreferredReactionsModal.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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}
|
||||||
|
|
46
ts/test-both/reactions/getPreferredReactionEmoji_test.ts
Normal file
46
ts/test-both/reactions/getPreferredReactionEmoji_test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
424
ts/test-both/state/ducks/preferredReactions_test.ts
Normal file
424
ts/test-both/state/ducks/preferredReactions_test.ts
Normal 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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
56
ts/test-both/state/selectors/preferredReactions_test.ts
Normal file
56
ts/test-both/state/selectors/preferredReactions_test.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
32
ts/test-both/util/replaceIndex_test.ts
Normal file
32
ts/test-both/util/replaceIndex_test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
1
ts/types/Storage.d.ts
vendored
1
ts/types/Storage.d.ts
vendored
|
@ -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<{
|
||||||
|
|
|
@ -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
16
ts/util/replaceIndex.ts
Normal 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;
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
Loading…
Reference in a new issue