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": {
|
||||
"message": "Save",
|
||||
"description": "Used as a 'commit changes' button in the Caption Editor for outgoing image attachments"
|
||||
"description": "Used on save buttons"
|
||||
},
|
||||
"reset": {
|
||||
"message": "Reset",
|
||||
"description": "Used on reset buttons"
|
||||
},
|
||||
"fileIconAlt": {
|
||||
"message": "File icon",
|
||||
|
@ -6301,6 +6305,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"CustomizingPreferredReactions__title": {
|
||||
"message": "Customize default reactions",
|
||||
"description": "Shown in the header of the modal for customizing the preferred reactions. Also shown in the tooltip for the button that opens this modal."
|
||||
},
|
||||
"CustomizingPreferredReactions__subtitle": {
|
||||
"message": "Click to replace an emoji",
|
||||
"description": "Instructions in the modal for customizing the preferred reactions."
|
||||
},
|
||||
"CustomizingPreferredReactions__had-save-error": {
|
||||
"message": "There was an error when saving your settings. Please try again.",
|
||||
"description": "Shown if there is an error when saving your preferred reaction settings. Should be very rare to see this message."
|
||||
},
|
||||
"WhatsNew__modal-title": {
|
||||
"message": "What's New",
|
||||
"description": "Title for the whats new modal"
|
||||
|
|
|
@ -7439,6 +7439,19 @@ button.module-image__border-overlay:focus {
|
|||
&__footer {
|
||||
@extend %module-emoji-picker--ribbon;
|
||||
justify-content: center;
|
||||
|
||||
&__skin-tones {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__settings-spacer {
|
||||
width: 28px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
|
@ -7457,8 +7470,43 @@ button.module-image__border-overlay:focus {
|
|||
}
|
||||
|
||||
&--footer {
|
||||
&:not(:first-of-type) {
|
||||
margin-left: 4px;
|
||||
&:not(:last-of-type) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&--settings {
|
||||
margin-left: 12px;
|
||||
border-radius: 100%;
|
||||
|
||||
@include light-theme {
|
||||
background: $color-white;
|
||||
box-shadow: 0px 0px 4px $color-black-alpha-20;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
background: $color-gray-65;
|
||||
}
|
||||
|
||||
&::before {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
content: '';
|
||||
|
||||
@include light-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/settings-outline-16.svg',
|
||||
$color-gray-75
|
||||
);
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/settings-solid-16.svg',
|
||||
$color-gray-25
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
$big-emoji-size: 42px;
|
||||
|
||||
$root-selector: &;
|
||||
|
||||
@include rounded-corners;
|
||||
align-items: center;
|
||||
border-style: solid;
|
||||
|
@ -19,7 +21,6 @@
|
|||
padding: 3px 7px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
z-index: 2;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: {
|
||||
|
@ -48,31 +49,14 @@
|
|||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
// Prevent animation jank
|
||||
opacity: 0;
|
||||
|
||||
animation: {
|
||||
name: module-ReactionPicker__button-appear;
|
||||
duration: 400ms;
|
||||
timing-function: $ease-out-expo;
|
||||
fill-mode: forwards;
|
||||
// This delay is a fallback in case there are more than the expected number of
|
||||
// buttons.
|
||||
delay: #{$max-expected-buttons * 10ms};
|
||||
}
|
||||
}
|
||||
@for $i from 0 through $max-expected-buttons {
|
||||
&:nth-of-type(#{$i + 1}) {
|
||||
animation-delay: #{$i * 10ms};
|
||||
}
|
||||
}
|
||||
|
||||
&--emoji {
|
||||
$emoji-button-selector: &;
|
||||
|
||||
height: $button-size;
|
||||
width: $button-size;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
transition: background 200ms $ease-out-expo;
|
||||
}
|
||||
|
||||
.module-emoji {
|
||||
transform: scale($button-content-size / $emoji-size-from-component);
|
||||
|
@ -80,25 +64,6 @@
|
|||
transition: transform 400ms $ease-out-expo;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin focused-emoji {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.module-emoji {
|
||||
transform: scale($big-emoji-size / $emoji-size-from-component)
|
||||
translateY(-16px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@include focused-emoji;
|
||||
}
|
||||
|
||||
@include keyboard-mode {
|
||||
&:focus {
|
||||
@include focused-emoji;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--more {
|
||||
|
@ -117,6 +82,11 @@
|
|||
&:hover {
|
||||
background: $color-gray-05;
|
||||
}
|
||||
@include keyboard-mode {
|
||||
&:focus {
|
||||
background: $color-gray-05;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
|
@ -125,6 +95,11 @@
|
|||
&:hover {
|
||||
background: $color-gray-45;
|
||||
}
|
||||
@include dark-keyboard-mode {
|
||||
&:focus {
|
||||
background: $color-gray-45;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
|
@ -146,14 +121,101 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--selected {
|
||||
@include light-theme {
|
||||
background: $color-black-alpha-20;
|
||||
&--picker-style {
|
||||
z-index: 2;
|
||||
|
||||
#{$root-selector}__button {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
// Prevent animation jank
|
||||
opacity: 0;
|
||||
|
||||
animation: {
|
||||
name: module-ReactionPicker__button-appear;
|
||||
duration: 400ms;
|
||||
timing-function: $ease-out-expo;
|
||||
fill-mode: forwards;
|
||||
// This delay is a fallback in case there are more than the expected number of
|
||||
// buttons.
|
||||
delay: #{$max-expected-buttons * 10ms};
|
||||
}
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
background: $color-white-alpha-20;
|
||||
@for $i from 0 through $max-expected-buttons {
|
||||
&:nth-of-type(#{$i + 1}) {
|
||||
animation-delay: #{$i * 10ms};
|
||||
}
|
||||
}
|
||||
|
||||
&--emoji {
|
||||
@mixin focus-or-hover-styles {
|
||||
.module-emoji {
|
||||
transform: scale($big-emoji-size / $emoji-size-from-component)
|
||||
translateY(-16px);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
@include focus-or-hover-styles;
|
||||
}
|
||||
@include keyboard-mode {
|
||||
&:focus {
|
||||
@include focus-or-hover-styles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--selected {
|
||||
@include light-theme {
|
||||
background: $color-black-alpha-20;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
background: $color-white-alpha-20;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--menu-style {
|
||||
#{$root-selector}__button {
|
||||
$light-focus-or-hover-background: $color-black-alpha-20;
|
||||
$dark-focus-or-hover-background: $color-white-alpha-40;
|
||||
|
||||
&:hover {
|
||||
@include light-theme {
|
||||
background: $light-focus-or-hover-background;
|
||||
}
|
||||
@include dark-theme {
|
||||
background: $dark-focus-or-hover-background;
|
||||
}
|
||||
}
|
||||
@include keyboard-mode {
|
||||
&:focus {
|
||||
background: $light-focus-or-hover-background;
|
||||
}
|
||||
}
|
||||
@include dark-keyboard-mode {
|
||||
&:focus {
|
||||
background: $dark-focus-or-hover-background;
|
||||
}
|
||||
}
|
||||
|
||||
#{$root-selector}--something-selected {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
opacity: 1;
|
||||
|
||||
.module-emoji {
|
||||
transform: scale($big-emoji-size / $emoji-size-from-component);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: module-ReactionPicker__button-selected 1s ease-in-out
|
||||
infinite alternate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -180,3 +242,13 @@
|
|||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes module-ReactionPicker__button-selected {
|
||||
from {
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
@import './components/ContactSpoofingReviewDialogPerson.scss';
|
||||
@import './components/ConversationHeader.scss';
|
||||
@import './components/CustomColorEditor.scss';
|
||||
@import './components/CustomizingPreferredReactionsModal.scss';
|
||||
@import './components/DisappearingTimeDialog.scss';
|
||||
@import './components/DisappearingTimerSelect.scss';
|
||||
@import './components/EditConversationAttributesModal.scss';
|
||||
|
|
|
@ -89,6 +89,7 @@ import {
|
|||
SendStatus,
|
||||
} from './messages/MessageSendState';
|
||||
import * as AttachmentDownloads from './messageModifiers/AttachmentDownloads';
|
||||
import * as preferredReactions from './state/ducks/preferredReactions';
|
||||
import * as Stickers from './types/Stickers';
|
||||
import { SignalService as Proto } from './protobuf';
|
||||
import { onRetryRequest, onDecryptionError } from './util/handleRetry';
|
||||
|
@ -953,6 +954,7 @@ export async function startApp(): Promise<void> {
|
|||
},
|
||||
emojis: window.Signal.Emojis.getInitialState(),
|
||||
items: window.storage.getItemsState(),
|
||||
preferredReactions: preferredReactions.getInitialState(),
|
||||
stickers: Stickers.getInitialState(),
|
||||
user: {
|
||||
attachmentsPath: window.baseAttachmentsPath,
|
||||
|
|
|
@ -24,8 +24,10 @@ export const App = ({
|
|||
conversationsStoppingMessageSendBecauseOfVerification,
|
||||
hasInitialLoadCompleted,
|
||||
i18n,
|
||||
isCustomizingPreferredReactions,
|
||||
numberOfMessagesPendingBecauseOfVerification,
|
||||
renderCallManager,
|
||||
renderCustomizingPreferredReactionsModal,
|
||||
renderGlobalModalContainer,
|
||||
renderSafetyNumber,
|
||||
theme,
|
||||
|
@ -48,9 +50,13 @@ export const App = ({
|
|||
}
|
||||
hasInitialLoadCompleted={hasInitialLoadCompleted}
|
||||
i18n={i18n}
|
||||
isCustomizingPreferredReactions={isCustomizingPreferredReactions}
|
||||
numberOfMessagesPendingBecauseOfVerification={
|
||||
numberOfMessagesPendingBecauseOfVerification
|
||||
}
|
||||
renderCustomizingPreferredReactionsModal={
|
||||
renderCustomizingPreferredReactionsModal
|
||||
}
|
||||
renderSafetyNumber={renderSafetyNumber}
|
||||
verifyConversationsStoppingMessageSend={
|
||||
verifyConversationsStoppingMessageSend
|
||||
|
|
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>;
|
||||
hasInitialLoadCompleted: boolean;
|
||||
i18n: LocalizerType;
|
||||
isCustomizingPreferredReactions: boolean;
|
||||
numberOfMessagesPendingBecauseOfVerification: number;
|
||||
renderCustomizingPreferredReactionsModal: () => JSX.Element;
|
||||
renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element;
|
||||
verifyConversationsStoppingMessageSend: () => void;
|
||||
};
|
||||
|
@ -34,7 +36,9 @@ export const Inbox = ({
|
|||
conversationsStoppingMessageSendBecauseOfVerification,
|
||||
hasInitialLoadCompleted,
|
||||
i18n,
|
||||
isCustomizingPreferredReactions,
|
||||
numberOfMessagesPendingBecauseOfVerification,
|
||||
renderCustomizingPreferredReactionsModal,
|
||||
renderSafetyNumber,
|
||||
verifyConversationsStoppingMessageSend,
|
||||
}: PropsType): JSX.Element => {
|
||||
|
@ -67,7 +71,7 @@ export const Inbox = ({
|
|||
}
|
||||
}, [hasInitialLoadCompleted, viewRef]);
|
||||
|
||||
let safetyNumberChangeDialog: ReactNode;
|
||||
let activeModal: ReactNode;
|
||||
if (conversationsStoppingMessageSendBecauseOfVerification.length) {
|
||||
const confirmText: string =
|
||||
numberOfMessagesPendingBecauseOfVerification === 1
|
||||
|
@ -75,7 +79,7 @@ export const Inbox = ({
|
|||
: i18n('safetyNumberChangeDialog__pending-messages--many', [
|
||||
numberOfMessagesPendingBecauseOfVerification.toString(),
|
||||
]);
|
||||
safetyNumberChangeDialog = (
|
||||
activeModal = (
|
||||
<SafetyNumberChangeDialog
|
||||
confirmText={confirmText}
|
||||
contacts={conversationsStoppingMessageSendBecauseOfVerification}
|
||||
|
@ -86,11 +90,14 @@ export const Inbox = ({
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (!activeModal && isCustomizingPreferredReactions) {
|
||||
activeModal = renderCustomizingPreferredReactionsModal();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -9,11 +9,24 @@ import { action } from '@storybook/addon-actions';
|
|||
import { select } from '@storybook/addon-knobs';
|
||||
import { setup as setupI18n } from '../../../js/modules/i18n';
|
||||
import enMessages from '../../../_locales/en/messages.json';
|
||||
import { Props as ReactionPickerProps, ReactionPicker } from './ReactionPicker';
|
||||
import {
|
||||
Props as ReactionPickerProps,
|
||||
ReactionPicker,
|
||||
ReactionPickerSelectionStyle,
|
||||
} from './ReactionPicker';
|
||||
import { EmojiPicker } from '../emoji/EmojiPicker';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const preferredReactionEmoji = [
|
||||
'heart',
|
||||
'thumbsup',
|
||||
'thumbsdown',
|
||||
'joy',
|
||||
'open_mouth',
|
||||
'cry',
|
||||
];
|
||||
|
||||
const renderEmojiPicker: ReactionPickerProps['renderEmojiPicker'] = ({
|
||||
onClose,
|
||||
onPickEmoji,
|
||||
|
@ -35,7 +48,12 @@ storiesOf('Components/Conversation/ReactionPicker', module)
|
|||
<ReactionPicker
|
||||
i18n={i18n}
|
||||
onPick={action('onPick')}
|
||||
openCustomizePreferredReactionsModal={action(
|
||||
'openCustomizePreferredReactionsModal'
|
||||
)}
|
||||
preferredReactionEmoji={preferredReactionEmoji}
|
||||
renderEmojiPicker={renderEmojiPicker}
|
||||
selectionStyle={ReactionPickerSelectionStyle.Picker}
|
||||
skinTone={0}
|
||||
/>
|
||||
);
|
||||
|
@ -47,7 +65,12 @@ storiesOf('Components/Conversation/ReactionPicker', module)
|
|||
i18n={i18n}
|
||||
selected={e}
|
||||
onPick={action('onPick')}
|
||||
openCustomizePreferredReactionsModal={action(
|
||||
'openCustomizePreferredReactionsModal'
|
||||
)}
|
||||
preferredReactionEmoji={preferredReactionEmoji}
|
||||
renderEmojiPicker={renderEmojiPicker}
|
||||
selectionStyle={ReactionPickerSelectionStyle.Picker}
|
||||
skinTone={0}
|
||||
/>
|
||||
</div>
|
||||
|
@ -60,7 +83,12 @@ storiesOf('Components/Conversation/ReactionPicker', module)
|
|||
i18n={i18n}
|
||||
selected={e}
|
||||
onPick={action('onPick')}
|
||||
openCustomizePreferredReactionsModal={action(
|
||||
'openCustomizePreferredReactionsModal'
|
||||
)}
|
||||
preferredReactionEmoji={preferredReactionEmoji}
|
||||
renderEmojiPicker={renderEmojiPicker}
|
||||
selectionStyle={ReactionPickerSelectionStyle.Picker}
|
||||
skinTone={select(
|
||||
'skinTone',
|
||||
{ 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 },
|
||||
|
|
|
@ -1,39 +1,41 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as log from '../../logging/log';
|
||||
import { Emoji } from '../emoji/Emoji';
|
||||
import { convertShortName } from '../emoji/lib';
|
||||
import { Props as EmojiPickerProps } from '../emoji/EmojiPicker';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { useRestoreFocus } from '../../util/hooks/useRestoreFocus';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
export enum ReactionPickerSelectionStyle {
|
||||
Picker,
|
||||
Menu,
|
||||
}
|
||||
|
||||
export type RenderEmojiPickerProps = Pick<Props, 'onClose' | 'style'> &
|
||||
Pick<EmojiPickerProps, 'onPickEmoji'> & {
|
||||
Pick<EmojiPickerProps, 'onClickSettings' | 'onPickEmoji'> & {
|
||||
ref: React.Ref<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export type OwnProps = {
|
||||
hasMoreButton?: boolean;
|
||||
i18n: LocalizerType;
|
||||
selected?: string;
|
||||
selectionStyle: ReactionPickerSelectionStyle;
|
||||
onClose?: () => unknown;
|
||||
onPick: (emoji: string) => unknown;
|
||||
openCustomizePreferredReactionsModal?: () => unknown;
|
||||
preferredReactionEmoji: Array<string>;
|
||||
renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement;
|
||||
skinTone: number;
|
||||
};
|
||||
|
||||
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
|
||||
|
||||
const DEFAULT_EMOJI_LIST = [
|
||||
'heart',
|
||||
'thumbsup',
|
||||
'thumbsdown',
|
||||
'joy',
|
||||
'open_mouth',
|
||||
'cry',
|
||||
];
|
||||
|
||||
const EmojiButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
|
@ -64,7 +66,19 @@ const EmojiButton = React.forwardRef<
|
|||
|
||||
export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{ i18n, selected, onClose, skinTone, onPick, renderEmojiPicker, style },
|
||||
{
|
||||
hasMoreButton = true,
|
||||
i18n,
|
||||
onClose,
|
||||
onPick,
|
||||
openCustomizePreferredReactionsModal,
|
||||
preferredReactionEmoji,
|
||||
renderEmojiPicker,
|
||||
selected,
|
||||
selectionStyle,
|
||||
skinTone,
|
||||
style,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [pickingOther, setPickingOther] = React.useState(false);
|
||||
|
@ -96,17 +110,25 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
|
|||
const [focusRef] = useRestoreFocus();
|
||||
|
||||
if (pickingOther) {
|
||||
return renderEmojiPicker({ onPickEmoji, onClose, style, ref });
|
||||
return renderEmojiPicker({
|
||||
onClickSettings: openCustomizePreferredReactionsModal,
|
||||
onClose,
|
||||
onPickEmoji,
|
||||
ref,
|
||||
style,
|
||||
});
|
||||
}
|
||||
|
||||
const emojis = DEFAULT_EMOJI_LIST.map(shortName =>
|
||||
const emojis = preferredReactionEmoji.map(shortName =>
|
||||
convertShortName(shortName, skinTone)
|
||||
);
|
||||
|
||||
const otherSelected = selected && !emojis.includes(selected);
|
||||
|
||||
let moreButton: React.ReactNode;
|
||||
if (otherSelected) {
|
||||
if (!hasMoreButton) {
|
||||
moreButton = undefined;
|
||||
} else if (otherSelected) {
|
||||
moreButton = (
|
||||
<EmojiButton
|
||||
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 (
|
||||
<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) => {
|
||||
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
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -70,4 +70,17 @@ storiesOf('Components/Emoji/EmojiPicker', module)
|
|||
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
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -34,6 +34,7 @@ export type OwnProps = {
|
|||
readonly skinTone?: number;
|
||||
readonly onSetSkinTone?: (tone: number) => unknown;
|
||||
readonly recentEmojis?: Array<string>;
|
||||
readonly onClickSettings?: () => unknown;
|
||||
readonly onClose?: () => unknown;
|
||||
};
|
||||
|
||||
|
@ -70,6 +71,7 @@ export const EmojiPicker = React.memo(
|
|||
onSetSkinTone,
|
||||
recentEmojis = [],
|
||||
style,
|
||||
onClickSettings,
|
||||
onClose,
|
||||
}: Props,
|
||||
ref
|
||||
|
@ -383,24 +385,38 @@ export const EmojiPicker = React.memo(
|
|||
</div>
|
||||
)}
|
||||
<footer className="module-emoji-picker__footer">
|
||||
{[0, 1, 2, 3, 4, 5].map(tone => (
|
||||
{Boolean(onClickSettings) && (
|
||||
<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"
|
||||
key={tone}
|
||||
data-tone={tone}
|
||||
onClick={handlePickTone}
|
||||
title={i18n('EmojiPicker--skin-tone', [`${tone}`])}
|
||||
className={classNames(
|
||||
'module-emoji-picker__button',
|
||||
'module-emoji-picker__button--footer',
|
||||
selectedTone === tone
|
||||
? 'module-emoji-picker__button--selected'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<Emoji shortName="hand" skinTone={tone} size={20} />
|
||||
</button>
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
<div className="module-emoji-picker__footer__skin-tones">
|
||||
{[0, 1, 2, 3, 4, 5].map(tone => (
|
||||
<button
|
||||
type="button"
|
||||
key={tone}
|
||||
data-tone={tone}
|
||||
onClick={handlePickTone}
|
||||
title={i18n('EmojiPicker--skin-tone', [`${tone}`])}
|
||||
className={classNames(
|
||||
'module-emoji-picker__button',
|
||||
'module-emoji-picker__button--footer',
|
||||
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>
|
||||
</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 customColors?: CustomColorsItemType;
|
||||
|
||||
readonly preferredReactionEmoji?: Array<string>;
|
||||
};
|
||||
|
||||
// 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
|
||||
|
||||
import { combineReducers } from 'redux';
|
||||
|
@ -15,6 +15,7 @@ import { reducer as globalModals } from './ducks/globalModals';
|
|||
import { reducer as items } from './ducks/items';
|
||||
import { reducer as linkPreviews } from './ducks/linkPreviews';
|
||||
import { reducer as network } from './ducks/network';
|
||||
import { reducer as preferredReactions } from './ducks/preferredReactions';
|
||||
import { reducer as safetyNumber } from './ducks/safetyNumber';
|
||||
import { reducer as search } from './ducks/search';
|
||||
import { reducer as stickers } from './ducks/stickers';
|
||||
|
@ -34,6 +35,7 @@ export const reducer = combineReducers({
|
|||
items,
|
||||
linkPreviews,
|
||||
network,
|
||||
preferredReactions,
|
||||
safetyNumber,
|
||||
search,
|
||||
stickers,
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { createSelector } from 'reselect';
|
||||
import { isInteger } from 'lodash';
|
||||
|
||||
import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer';
|
||||
|
||||
|
@ -12,6 +13,7 @@ import {
|
|||
CustomColorType,
|
||||
DEFAULT_CONVERSATION_COLOR,
|
||||
} from '../../types/Colors';
|
||||
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/getPreferredReactionEmoji';
|
||||
|
||||
export const getItems = (state: StateType): ItemsStateType => state.items;
|
||||
|
||||
|
@ -49,3 +51,20 @@ export const getCustomColors = createSelector(
|
|||
(state: ItemsStateType): Record<string, CustomColorType> | undefined =>
|
||||
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 { SmartCallManager } from './CallManager';
|
||||
import { SmartCustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal';
|
||||
import { SmartGlobalModalContainer } from './GlobalModalContainer';
|
||||
import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
|
||||
import { StateType } from '../reducer';
|
||||
|
@ -14,6 +15,7 @@ import {
|
|||
getConversationsStoppingMessageSendBecauseOfVerification,
|
||||
getNumberOfMessagesPendingBecauseOfVerification,
|
||||
} from '../selectors/conversations';
|
||||
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog';
|
||||
|
||||
|
@ -24,10 +26,14 @@ const mapStateToProps = (state: StateType) => {
|
|||
state
|
||||
),
|
||||
i18n: getIntl(state),
|
||||
isCustomizingPreferredReactions: getIsCustomizingPreferredReactions(state),
|
||||
numberOfMessagesPendingBecauseOfVerification: getNumberOfMessagesPendingBecauseOfVerification(
|
||||
state
|
||||
),
|
||||
renderCallManager: () => <SmartCallManager />,
|
||||
renderCustomizingPreferredReactionsModal: () => (
|
||||
<SmartCustomizingPreferredReactionsModal />
|
||||
),
|
||||
renderGlobalModalContainer: () => <SmartGlobalModalContainer />,
|
||||
renderSafetyNumber: (props: SafetyNumberProps) => (
|
||||
<SmartSafetyNumberViewer {...props} />
|
||||
|
|
|
@ -10,6 +10,7 @@ import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
|
|||
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
import { getIntl, getUserConversationId } from '../selectors/user';
|
||||
import { getEmojiSkinTone } from '../selectors/items';
|
||||
import {
|
||||
getConversationSelector,
|
||||
getGroupAdminsSelector,
|
||||
|
@ -100,7 +101,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
},
|
||||
// Emojis
|
||||
recentEmojis,
|
||||
skinTone: get(state, ['items', 'skinTone'], 0),
|
||||
skinTone: getEmojiSkinTone(state),
|
||||
// Stickers
|
||||
receivedPacks,
|
||||
installedPack,
|
||||
|
|
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
|
||||
|
||||
import * as React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { get } from 'lodash';
|
||||
import { StateType } from '../reducer';
|
||||
import { useRecentEmojis } from '../selectors/emojis';
|
||||
import { useActions as useEmojiActions } from '../ducks/emojis';
|
||||
|
@ -13,15 +12,19 @@ import {
|
|||
Props as EmojiPickerProps,
|
||||
} from '../../components/emoji/EmojiPicker';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getEmojiSkinTone } from '../selectors/items';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
export const SmartEmojiPicker = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
Pick<EmojiPickerProps, 'onPickEmoji' | 'onSetSkinTone' | 'onClose' | 'style'>
|
||||
>(({ onPickEmoji, onSetSkinTone, onClose, style }, ref) => {
|
||||
Pick<
|
||||
EmojiPickerProps,
|
||||
'onClickSettings' | 'onPickEmoji' | 'onSetSkinTone' | 'onClose' | 'style'
|
||||
>
|
||||
>(({ onClickSettings, onPickEmoji, onSetSkinTone, onClose, style }, ref) => {
|
||||
const i18n = useSelector<StateType, LocalizerType>(getIntl);
|
||||
const skinTone = useSelector<StateType, number>(state =>
|
||||
get(state, ['items', 'skinTone'], 0)
|
||||
getEmojiSkinTone(state)
|
||||
);
|
||||
|
||||
const recentEmojis = useRecentEmojis();
|
||||
|
@ -41,6 +44,7 @@ export const SmartEmojiPicker = React.forwardRef<
|
|||
ref={ref}
|
||||
i18n={i18n}
|
||||
skinTone={skinTone}
|
||||
onClickSettings={onClickSettings}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
onPickEmoji={handlePickEmoji}
|
||||
recentEmojis={recentEmojis}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { get } from 'lodash';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
import {
|
||||
ForwardMessageModal,
|
||||
|
@ -14,6 +13,7 @@ import { LinkPreviewType } from '../../types/message/LinkPreviews';
|
|||
import { getAllComposableConversations } from '../selectors/conversations';
|
||||
import { getLinkPreview } from '../selectors/linkPreviews';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getEmojiSkinTone } from '../selectors/items';
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
import { AttachmentType } from '../../types/Attachment';
|
||||
|
||||
|
@ -52,7 +52,7 @@ const mapStateToProps = (
|
|||
|
||||
const candidateConversations = getAllComposableConversations(state);
|
||||
const recentEmojis = selectRecentEmojis(state);
|
||||
const skinTone = get(state, ['items', 'skinTone'], 0);
|
||||
const skinTone = getEmojiSkinTone(state);
|
||||
const linkPreview = getLinkPreview(state);
|
||||
|
||||
return {
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { get } from 'lodash';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
import {
|
||||
ProfileEditorModal,
|
||||
|
@ -11,6 +10,7 @@ import {
|
|||
import { PropsDataType } from '../../components/ProfileEditor';
|
||||
import { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getEmojiSkinTone } from '../selectors/items';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
|
||||
|
@ -28,7 +28,7 @@ function mapStateToProps(
|
|||
id: conversationId,
|
||||
} = getMe(state);
|
||||
const recentEmojis = selectRecentEmojis(state);
|
||||
const skinTone = get(state, ['items', 'skinTone'], 0);
|
||||
const skinTone = getEmojiSkinTone(state);
|
||||
|
||||
return {
|
||||
aboutEmoji,
|
||||
|
|
|
@ -1,32 +1,62 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { get } from 'lodash';
|
||||
import { StateType } from '../reducer';
|
||||
import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions';
|
||||
|
||||
import { getIntl } from '../selectors/user';
|
||||
import {
|
||||
getEmojiSkinTone,
|
||||
getPreferredReactionEmoji,
|
||||
} from '../selectors/items';
|
||||
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import {
|
||||
ReactionPicker,
|
||||
ReactionPickerSelectionStyle,
|
||||
Props,
|
||||
} from '../../components/conversation/ReactionPicker';
|
||||
|
||||
type ExternalProps = Omit<Props, 'skinTone' | 'i18n'>;
|
||||
type ExternalProps = Omit<
|
||||
Props,
|
||||
| 'i18n'
|
||||
| 'openCustomizePreferredReactionsModal'
|
||||
| 'preferredReactionEmoji'
|
||||
| 'selectionStyle'
|
||||
| 'skinTone'
|
||||
>;
|
||||
|
||||
export const SmartReactionPicker = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ExternalProps
|
||||
>((props, ref) => {
|
||||
const {
|
||||
openCustomizePreferredReactionsModal,
|
||||
} = usePreferredReactionsActions();
|
||||
|
||||
const i18n = useSelector<StateType, LocalizerType>(getIntl);
|
||||
|
||||
const preferredReactionEmoji = useSelector<StateType, Array<string>>(
|
||||
getPreferredReactionEmoji
|
||||
);
|
||||
|
||||
const skinTone = useSelector<StateType, number>(state =>
|
||||
get(state, ['items', 'skinTone'], 0)
|
||||
getEmojiSkinTone(state)
|
||||
);
|
||||
|
||||
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({
|
||||
ref,
|
||||
onClickSettings,
|
||||
onPickEmoji,
|
||||
onClose,
|
||||
style,
|
||||
|
@ -15,6 +16,7 @@ export function renderEmojiPicker({
|
|||
return (
|
||||
<SmartEmojiPicker
|
||||
ref={ref}
|
||||
onClickSettings={onClickSettings}
|
||||
onPickEmoji={onPickEmoji}
|
||||
onClose={onClose}
|
||||
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
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { getPinnedConversationIds } from '../../../state/selectors/items';
|
||||
import {
|
||||
getEmojiSkinTone,
|
||||
getPinnedConversationIds,
|
||||
getPreferredReactionEmoji,
|
||||
} from '../../../state/selectors/items';
|
||||
import type { StateType } from '../../../state/reducer';
|
||||
import type { ItemsStateType } from '../../../state/ducks/items';
|
||||
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../../../reactions/constants';
|
||||
|
||||
describe('both/state/selectors/items', () => {
|
||||
describe('#getPinnedConversationIds', () => {
|
||||
// Note: we would like to use the full reducer here, to get a real empty state object
|
||||
// but we cannot load the full reducer inside of electron-mocha.
|
||||
function getDefaultStateType(): StateType {
|
||||
return {
|
||||
items: {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
// Note: we would like to use the full reducer here, to get a real empty state object
|
||||
// but we cannot load the full reducer inside of electron-mocha.
|
||||
function getRootState(items: ItemsStateType): StateType {
|
||||
return {
|
||||
items,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('#getEmojiSkinTone', () => {
|
||||
it('returns 0 if passed anything invalid', () => {
|
||||
[
|
||||
// Invalid types
|
||||
undefined,
|
||||
null,
|
||||
'2',
|
||||
[2],
|
||||
// Numbers out of range
|
||||
-1,
|
||||
6,
|
||||
Infinity,
|
||||
// Invalid numbers
|
||||
0.1,
|
||||
1.2,
|
||||
NaN,
|
||||
].forEach(skinTone => {
|
||||
const state = getRootState({ skinTone });
|
||||
assert.strictEqual(getEmojiSkinTone(state), 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns all valid skin tones', () => {
|
||||
[0, 1, 2, 3, 4, 5].forEach(skinTone => {
|
||||
const state = getRootState({ skinTone });
|
||||
assert.strictEqual(getEmojiSkinTone(state), skinTone);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getPinnedConversationIds', () => {
|
||||
it('returns pinnedConversationIds key from items', () => {
|
||||
const expected = ['one', 'two'];
|
||||
const state: StateType = {
|
||||
...getDefaultStateType(),
|
||||
items: {
|
||||
pinnedConversationIds: expected,
|
||||
},
|
||||
};
|
||||
const state: StateType = getRootState({
|
||||
pinnedConversationIds: expected,
|
||||
});
|
||||
|
||||
const actual = getPinnedConversationIds(state);
|
||||
assert.deepEqual(actual, expected);
|
||||
|
@ -31,10 +64,43 @@ describe('both/state/selectors/items', () => {
|
|||
|
||||
it('returns empty array if no saved data', () => {
|
||||
const expected: Array<string> = [];
|
||||
const state = getDefaultStateType();
|
||||
const state = getRootState({});
|
||||
|
||||
const actual = getPinnedConversationIds(state);
|
||||
assert.deepEqual(actual, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getPreferredReactionEmoji', () => {
|
||||
// See also: the tests for the `getPreferredReactionEmoji` helper.
|
||||
|
||||
it('returns the default set if no value is stored', () => {
|
||||
const state = getRootState({});
|
||||
const actual = getPreferredReactionEmoji(state);
|
||||
|
||||
assert.deepStrictEqual(actual, DEFAULT_PREFERRED_REACTION_EMOJI);
|
||||
});
|
||||
|
||||
it('returns the default set if the stored value is invalid', () => {
|
||||
const state = getRootState({ preferredReactionEmoji: ['garbage!!'] });
|
||||
const actual = getPreferredReactionEmoji(state);
|
||||
|
||||
assert.deepStrictEqual(actual, DEFAULT_PREFERRED_REACTION_EMOJI);
|
||||
});
|
||||
|
||||
it('returns a custom set of emoji', () => {
|
||||
const preferredReactionEmoji = [
|
||||
'sparkles',
|
||||
'sparkle',
|
||||
'sparkler',
|
||||
'shark',
|
||||
'sparkling_heart',
|
||||
'parking',
|
||||
];
|
||||
const state = getRootState({ preferredReactionEmoji });
|
||||
const actual = getPreferredReactionEmoji(state);
|
||||
|
||||
assert.deepStrictEqual(actual, preferredReactionEmoji);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
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;
|
||||
groupCredentials: Array<GroupCredentialType>;
|
||||
lastReceivedAtCounter: number;
|
||||
preferredReactionEmoji: Array<string>;
|
||||
skinTone: number;
|
||||
unreadCount: number;
|
||||
'challenge:retry-message-ids': ReadonlyArray<{
|
||||
|
|
|
@ -25,6 +25,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray<keyof StorageAccessType> = [
|
|||
'preferred-video-input-device',
|
||||
'preferred-audio-input-device',
|
||||
'preferred-audio-output-device',
|
||||
'preferredReactionEmoji',
|
||||
'skinTone',
|
||||
'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 { strictAssert } from '../util/assert';
|
||||
import { maybeParseUrl } from '../util/url';
|
||||
import { replaceIndex } from '../util/replaceIndex';
|
||||
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
|
||||
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
|
||||
import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
|
||||
|
@ -1925,10 +1926,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
|||
draftAttachments: [...draftAttachments, onDisk],
|
||||
});
|
||||
} else {
|
||||
const toUpdate = [...draftAttachments];
|
||||
toUpdate.splice(index, 1, onDisk);
|
||||
this.model.set({
|
||||
draftAttachments: toUpdate,
|
||||
draftAttachments: replaceIndex(draftAttachments, index, onDisk),
|
||||
});
|
||||
}
|
||||
this.updateAttachmentsView();
|
||||
|
|
Loading…
Reference in a new issue