Allow duplicates in reaction palette

This commit is contained in:
Evan Hahn 2021-09-13 12:04:45 -05:00 committed by GitHub
parent 5a57e2b704
commit 240585ef94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 160 additions and 163 deletions

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
.module-CustomizingPreferredReactionsModal { .module-CustomizingPreferredReactionsModal {
&__reaction-picker-wrapper { &__small-emoji-picker-wrapper {
@include font-subtitle; @include font-subtitle;
align-items: center; align-items: center;
display: flex; display: flex;
@ -19,7 +19,7 @@
color: $color-gray-25; color: $color-gray-25;
} }
.module-ReactionPicker { .module-ReactionPickerPicker {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
} }

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
.module-ReactionPicker { .module-ReactionPickerPicker {
$button-size: 40px; $button-size: 40px;
$button-content-size: 28px; $button-content-size: 28px;
$max-expected-buttons: 7; $max-expected-buttons: 7;
@ -24,7 +24,7 @@
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
animation: { animation: {
name: module-ReactionPicker__appear; name: module-ReactionPickerPicker__appear;
duration: 400ms; duration: 400ms;
timing-function: $ease-out-expo; timing-function: $ease-out-expo;
fill-mode: forwards; fill-mode: forwards;
@ -132,7 +132,7 @@
opacity: 0; opacity: 0;
animation: { animation: {
name: module-ReactionPicker__button-appear; name: module-ReactionPickerPicker__button-appear;
duration: 400ms; duration: 400ms;
timing-function: $ease-out-expo; timing-function: $ease-out-expo;
fill-mode: forwards; fill-mode: forwards;
@ -213,7 +213,7 @@
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
animation: module-ReactionPicker__button-selected 1s ease-in-out animation: module-ReactionPickerPicker__button-selected 1s ease-in-out
infinite alternate; infinite alternate;
} }
} }
@ -221,7 +221,7 @@
} }
} }
@keyframes module-ReactionPicker__appear { @keyframes module-ReactionPickerPicker__appear {
from { from {
opacity: 0; opacity: 0;
} }
@ -231,7 +231,7 @@
} }
} }
@keyframes module-ReactionPicker__button-appear { @keyframes module-ReactionPickerPicker__button-appear {
from { from {
transform: translate3d(0, 24px, 0); transform: translate3d(0, 24px, 0);
opacity: 0; opacity: 0;
@ -243,7 +243,7 @@
} }
} }
@keyframes module-ReactionPicker__button-selected { @keyframes module-ReactionPickerPicker__button-selected {
from { from {
transform: rotate(-8deg); transform: rotate(-8deg);
} }

View file

@ -70,7 +70,7 @@
@import './components/Modal.scss'; @import './components/Modal.scss';
@import './components/Preferences.scss'; @import './components/Preferences.scss';
@import './components/ProfileEditor.scss'; @import './components/ProfileEditor.scss';
@import './components/ReactionPicker.scss'; @import './components/ReactionPickerPicker.scss';
@import './components/SafetyNumberChangeDialog.scss'; @import './components/SafetyNumberChangeDialog.scss';
@import './components/SafetyNumberViewer.scss'; @import './components/SafetyNumberViewer.scss';
@import './components/SearchInput.scss'; @import './components/SearchInput.scss';

View file

@ -1267,9 +1267,7 @@ export async function startApp(): Promise<void> {
return; return;
} }
const reactionPicker = document.querySelector( const reactionPicker = document.querySelector('.module-ReactionPicker');
'.module-reaction-picker'
);
if (reactionPicker) { if (reactionPicker) {
return; return;
} }

View file

@ -9,9 +9,10 @@ import type { LocalizerType } from '../types/Util';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { Button, ButtonVariant } from './Button'; import { Button, ButtonVariant } from './Button';
import { import {
ReactionPicker, ReactionPickerPicker,
ReactionPickerSelectionStyle, ReactionPickerPickerEmojiButton,
} from './conversation/ReactionPicker'; ReactionPickerPickerStyle,
} from './ReactionPickerPicker';
import { EmojiPicker } from './emoji/EmojiPicker'; import { EmojiPicker } from './emoji/EmojiPicker';
import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from '../reactions/constants'; import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from '../reactions/constants';
import { convertShortName } from './emoji/lib'; import { convertShortName } from './emoji/lib';
@ -96,19 +97,6 @@ export function CustomizingPreferredReactionsModal({
}; };
}, [isSomethingSelected, popperElement, deselectDraftEmoji]); }, [isSomethingSelected, popperElement, deselectDraftEmoji]);
const selected =
typeof selectedDraftEmojiIndex === 'number'
? draftPreferredReactions[selectedDraftEmojiIndex]
: undefined;
const onPick = isSaving
? noop
: (pickedEmoji: string) => {
selectDraftEmojiToBeReplaced(
draftPreferredReactions.findIndex(emoji => emoji === pickedEmoji)
);
};
const hasChanged = !isEqual( const hasChanged = !isEqual(
originalPreferredReactions, originalPreferredReactions,
draftPreferredReactions draftPreferredReactions
@ -132,25 +120,32 @@ export function CustomizingPreferredReactionsModal({
}} }}
title={i18n('CustomizingPreferredReactions__title')} title={i18n('CustomizingPreferredReactions__title')}
> >
<div className="module-CustomizingPreferredReactionsModal__reaction-picker-wrapper"> <div className="module-CustomizingPreferredReactionsModal__small-emoji-picker-wrapper">
<ReactionPicker <ReactionPickerPicker
hasMoreButton={false} isSomethingSelected={isSomethingSelected}
i18n={i18n} pickerStyle={ReactionPickerPickerStyle.Menu}
onPick={onPick}
onSetSkinTone={shouldNotBeCalled}
ref={setReferenceElement} ref={setReferenceElement}
preferredReactionEmoji={draftPreferredReactions} >
selected={selected} {draftPreferredReactions.map((emoji, index) => (
selectionStyle={ReactionPickerSelectionStyle.Menu} <ReactionPickerPickerEmojiButton
renderEmojiPicker={shouldNotBeCalled} emoji={emoji}
/> // The index is the only thing that uniquely identifies the emoji, because
// there can be duplicates in the list.
// eslint-disable-next-line react/no-array-index-key
key={index}
onClick={() => {
selectDraftEmojiToBeReplaced(index);
}}
isSelected={index === selectedDraftEmojiIndex}
/>
))}
</ReactionPickerPicker>
{hadSaveError {hadSaveError
? i18n('CustomizingPreferredReactions__had-save-error') ? i18n('CustomizingPreferredReactions__had-save-error')
: i18n('CustomizingPreferredReactions__subtitle')} : i18n('CustomizingPreferredReactions__subtitle')}
</div> </div>
{isSomethingSelected && ( {isSomethingSelected && (
<div <div
className="module-CustomizingPreferredReactionsModal__emoji-picker-wrapper"
ref={setPopperElement} ref={setPopperElement}
style={emojiPickerPopper.styles.popper} style={emojiPickerPopper.styles.popper}
{...emojiPickerPopper.attributes.popper} {...emojiPickerPopper.attributes.popper}
@ -195,7 +190,3 @@ export function CustomizingPreferredReactionsModal({
</Modal> </Modal>
); );
} }
function shouldNotBeCalled(): React.ReactElement {
throw new Error('This should not be called');
}

View file

@ -0,0 +1,91 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, ReactNode, forwardRef } from 'react';
import classNames from 'classnames';
import { Emoji } from './emoji/Emoji';
import type { LocalizerType } from '../types/Util';
export enum ReactionPickerPickerStyle {
Picker,
Menu,
}
export const ReactionPickerPickerEmojiButton = React.forwardRef<
HTMLButtonElement,
{
emoji: string;
isSelected: boolean;
onClick: () => unknown;
title?: string;
}
>(({ emoji, onClick, isSelected, title }, ref) => (
<button
type="button"
ref={ref}
tabIndex={0}
className={classNames(
'module-ReactionPickerPicker__button',
'module-ReactionPickerPicker__button--emoji',
isSelected && 'module-ReactionPickerPicker__button--selected'
)}
onClick={event => {
event.stopPropagation();
onClick();
}}
>
<Emoji size={48} emoji={emoji} title={title} />
</button>
));
export const ReactionPickerPickerMoreButton = ({
i18n,
onClick,
}: Readonly<{
i18n: LocalizerType;
onClick: () => unknown;
}>): JSX.Element => (
<button
aria-label={i18n('Reactions--more')}
className="module-ReactionPickerPicker__button module-ReactionPickerPicker__button--more"
onClick={event => {
event.stopPropagation();
onClick();
}}
tabIndex={0}
title={i18n('Reactions--more')}
type="button"
>
<div className="module-ReactionPickerPicker__button--more__dot" />
<div className="module-ReactionPickerPicker__button--more__dot" />
<div className="module-ReactionPickerPicker__button--more__dot" />
</button>
);
export const ReactionPickerPicker = forwardRef<
HTMLDivElement,
{
children: ReactNode;
isSomethingSelected: boolean;
pickerStyle: ReactionPickerPickerStyle;
style?: CSSProperties;
}
>(({ children, isSomethingSelected, pickerStyle, style }, ref) => (
<div
className={classNames(
'module-ReactionPickerPicker',
isSomethingSelected && 'module-ReactionPickerPicker--something-selected',
{
'module-ReactionPickerPicker--picker-style':
pickerStyle === ReactionPickerPickerStyle.Picker,
'module-ReactionPickerPicker--menu-style':
pickerStyle === ReactionPickerPickerStyle.Menu,
}
)}
ref={ref}
style={style}
>
{children}
</div>
));

View file

@ -8,11 +8,7 @@ import { action } from '@storybook/addon-actions';
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 { import { Props as ReactionPickerProps, ReactionPicker } from './ReactionPicker';
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);
@ -47,7 +43,6 @@ storiesOf('Components/Conversation/ReactionPicker', module)
)} )}
preferredReactionEmoji={preferredReactionEmoji} preferredReactionEmoji={preferredReactionEmoji}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
selectionStyle={ReactionPickerSelectionStyle.Picker}
/> />
); );
}) })
@ -64,7 +59,6 @@ storiesOf('Components/Conversation/ReactionPicker', module)
)} )}
preferredReactionEmoji={preferredReactionEmoji} preferredReactionEmoji={preferredReactionEmoji}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
selectionStyle={ReactionPickerSelectionStyle.Picker}
/> />
</div> </div>
)); ));

View file

@ -2,20 +2,17 @@
// 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 * as log from '../../logging/log';
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';
import { canCustomizePreferredReactions } from '../../util/canCustomizePreferredReactions'; import { canCustomizePreferredReactions } from '../../util/canCustomizePreferredReactions';
import {
export enum ReactionPickerSelectionStyle { ReactionPickerPicker,
Picker, ReactionPickerPickerEmojiButton,
Menu, ReactionPickerPickerMoreButton,
} ReactionPickerPickerStyle,
} from '../ReactionPickerPicker';
export type RenderEmojiPickerProps = Pick<Props, 'onClose' | 'style'> & export type RenderEmojiPickerProps = Pick<Props, 'onClose' | 'style'> &
Pick< Pick<
@ -26,10 +23,8 @@ export type RenderEmojiPickerProps = Pick<Props, 'onClose' | 'style'> &
}; };
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;
onSetSkinTone: (tone: number) => unknown; onSetSkinTone: (tone: number) => unknown;
@ -40,38 +35,9 @@ export type OwnProps = {
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>; export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
const EmojiButton = React.forwardRef<
HTMLButtonElement,
{
emoji: string;
onSelect: () => unknown;
selected: boolean;
title?: string;
}
>(({ emoji, onSelect, selected, title }, ref) => (
<button
type="button"
key={emoji}
ref={ref}
tabIndex={0}
className={classNames(
'module-ReactionPicker__button',
'module-ReactionPicker__button--emoji',
selected && 'module-ReactionPicker__button--selected'
)}
onClick={e => {
e.stopPropagation();
onSelect();
}}
>
<Emoji size={48} emoji={emoji} title={title} />
</button>
));
export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>( export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
( (
{ {
hasMoreButton = true,
i18n, i18n,
onClose, onClose,
onPick, onPick,
@ -80,7 +46,6 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
preferredReactionEmoji, preferredReactionEmoji,
renderEmojiPicker, renderEmojiPicker,
selected, selected,
selectionStyle,
style, style,
}, },
ref ref
@ -130,80 +95,63 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
selected && !preferredReactionEmoji.includes(selected); selected && !preferredReactionEmoji.includes(selected);
let moreButton: React.ReactNode; let moreButton: React.ReactNode;
if (!hasMoreButton) { if (otherSelected) {
moreButton = undefined;
} else if (otherSelected) {
moreButton = ( moreButton = (
<EmojiButton <ReactionPickerPickerEmojiButton
emoji={selected} emoji={selected}
onSelect={() => { onClick={() => {
onPick(selected); onPick(selected);
}} }}
selected isSelected
title={i18n('Reactions--remove')} title={i18n('Reactions--remove')}
/> />
); );
} else { } else {
moreButton = ( moreButton = (
<button <ReactionPickerPickerMoreButton
aria-label={i18n('Reactions--more')} i18n={i18n}
className="module-ReactionPicker__button module-ReactionPicker__button--more" onClick={() => {
onClick={event => {
event.stopPropagation();
setPickingOther(true); setPickingOther(true);
}} }}
tabIndex={0} />
title={i18n('Reactions--more')}
type="button"
>
<div className="module-ReactionPicker__button--more__dot" />
<div className="module-ReactionPicker__button--more__dot" />
<div className="module-ReactionPicker__button--more__dot" />
</button>
); );
} }
let selectionStyleClassName: string; // This logic is here to avoid selecting duplicate emoji.
switch (selectionStyle) { let hasSelectedSomething = false;
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 <ReactionPickerPicker
isSomethingSelected={typeof selected === 'number'}
pickerStyle={ReactionPickerPickerStyle.Picker}
ref={ref} ref={ref}
style={style} style={style}
className={classNames(
'module-ReactionPicker',
selectionStyleClassName,
selected ? 'module-ReactionPicker--something-selected' : undefined
)}
> >
{preferredReactionEmoji.map((emoji, index) => { {preferredReactionEmoji.map((emoji, index) => {
const maybeFocusRef = index === 0 ? focusRef : undefined; const maybeFocusRef = index === 0 ? focusRef : undefined;
const isSelected = !hasSelectedSomething && emoji === selected;
if (isSelected) {
hasSelectedSomething = true;
}
return ( return (
<EmojiButton <ReactionPickerPickerEmojiButton
emoji={emoji} emoji={emoji}
key={emoji} isSelected={isSelected}
onSelect={() => { // The index is the only thing that uniquely identifies the emoji, because
// there can be duplicates in the list.
// eslint-disable-next-line react/no-array-index-key
key={index}
onClick={() => {
onPick(emoji); onPick(emoji);
}} }}
ref={maybeFocusRef} ref={maybeFocusRef}
selected={emoji === selected}
/> />
); );
})} })}
{moreButton} {moreButton}
</div> </ReactionPickerPicker>
); );
} }
); );

View file

@ -15,15 +15,10 @@ export function getPreferredReactionEmoji(
const isStoredValueValid = const isStoredValueValid =
Array.isArray(storedValue) && Array.isArray(storedValue) &&
storedValue.length === PREFERRED_REACTION_EMOJI_COUNT && storedValue.length === PREFERRED_REACTION_EMOJI_COUNT &&
storedValue.every(isValidReactionEmoji) && storedValue.every(isValidReactionEmoji);
!hasDuplicates(storedValue);
return isStoredValueValid return isStoredValueValid
? storedValue ? storedValue
: DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES.map(shortName => : DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES.map(shortName =>
convertShortName(shortName, skinTone) convertShortName(shortName, skinTone)
); );
} }
function hasDuplicates(arr: ReadonlyArray<unknown>): boolean {
return new Set(arr).size !== arr.length;
}

View file

@ -248,10 +248,7 @@ export function reducer(
draftPreferredReactions, draftPreferredReactions,
selectedDraftEmojiIndex, selectedDraftEmojiIndex,
} = customizePreferredReactionsModal; } = customizePreferredReactionsModal;
if ( if (selectedDraftEmojiIndex === undefined) {
selectedDraftEmojiIndex === undefined ||
draftPreferredReactions.includes(newEmoji)
) {
return state; return state;
} }

View file

@ -13,7 +13,6 @@ import { 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';
@ -51,7 +50,6 @@ export const SmartReactionPicker = React.forwardRef<
} }
preferredReactionEmoji={preferredReactionEmoji} preferredReactionEmoji={preferredReactionEmoji}
ref={ref} ref={ref}
selectionStyle={ReactionPickerSelectionStyle.Picker}
{...props} {...props}
/> />
); );

View file

@ -24,8 +24,6 @@ describe('getPreferredReactionEmoji', () => {
['❤️', '👍', 'x', '😂', '😮', '😢'], ['❤️', '👍', 'x', '😂', '😮', '😢'],
['❤️', '👍', 'garbage!!', '😂', '😮', '😢'], ['❤️', '👍', 'garbage!!', '😂', '😮', '😢'],
['❤️', '👍', '✨✨', '😂', '😮', '😢'], ['❤️', '👍', '✨✨', '😂', '😮', '😢'],
// Has duplicates
['❤️', '👍', '👍', '😂', '😮', '😢'],
].forEach(input => { ].forEach(input => {
assert.deepStrictEqual(getPreferredReactionEmoji(input, 2), [ assert.deepStrictEqual(getPreferredReactionEmoji(input, 2), [
'❤️', '❤️',

View file

@ -180,19 +180,6 @@ describe('preferred reactions duck', () => {
assert.strictEqual(result, stateWithOpenCustomizationModal); assert.strictEqual(result, stateWithOpenCustomizationModal);
}); });
it('is a no-op if the new emoji is already in the list', () => {
const action = replaceSelectedDraftEmoji('✨');
const result = reducer(
stateWithOpenCustomizationModalAndSelectedEmoji,
action
);
assert.strictEqual(
result,
stateWithOpenCustomizationModalAndSelectedEmoji
);
});
it('replaces the selected draft emoji and deselects', () => { it('replaces the selected draft emoji and deselects', () => {
const action = replaceSelectedDraftEmoji('🐱'); const action = replaceSelectedDraftEmoji('🐱');
const result = reducer( const result = reducer(