Reactions: skin tone support

This commit is contained in:
Sidney Keese 2020-10-02 13:05:09 -07:00 committed by Josh Perez
parent 6a7d45b6fc
commit c3ddedfde1
13 changed files with 307 additions and 141 deletions

View File

@ -2,11 +2,7 @@ import * as React from 'react';
import { Editor } from 'draft-js';
import { get, noop } from 'lodash';
import classNames from 'classnames';
import {
EmojiButton,
EmojiPickDataType,
Props as EmojiButtonProps,
} from './emoji/EmojiButton';
import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton';
import {
Props as StickerButtonProps,
StickerButton,
@ -22,6 +18,7 @@ import {
} from './conversation/MessageRequestActions';
import { countStickers } from './stickers/lib';
import { LocalizerType } from '../types/Util';
import { EmojiPickDataType } from './emoji/EmojiPicker';
export type OwnProps = {
readonly i18n: LocalizerType;

View File

@ -20,7 +20,7 @@ import {
OwnProps as ReactionViewerProps,
ReactionViewer,
} from './ReactionViewer';
import { Props as ReactionPickerProps, ReactionPicker } from './ReactionPicker';
import { Props as ReactionPickerProps } from './ReactionPicker';
import { Emoji } from '../emoji/Emoji';
import { LinkPreviewDate } from './LinkPreviewDate';
@ -44,6 +44,8 @@ import { isFileDangerous } from '../../util/isFileDangerous';
import { BodyRangesType, LocalizerType } from '../../types/Util';
import { ColorType } from '../../types/Colors';
import { createRefMerger } from '../_util';
import { emojiToData } from '../emoji/lib';
import { SmartReactionPicker } from '../../state/smart/ReactionPicker';
interface Trigger {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
@ -1297,8 +1299,7 @@ export class Message extends React.PureComponent<Props, State> {
// eslint-disable-next-line consistent-return
<Popper placement="top">
{({ ref, style }) => (
<ReactionPicker
i18n={i18n}
<SmartReactionPicker
ref={ref}
style={style}
selected={selectedReaction}
@ -1726,13 +1727,24 @@ export class Message extends React.PureComponent<Props, State> {
return null;
}
const reactionsWithEmojiData = reactions.map(reaction => ({
...reaction,
...emojiToData(reaction.emoji),
}));
// Group by emoji and order each group by timestamp descending
const grouped = Object.values(groupBy(reactions, 'emoji')).map(res =>
orderBy(res, ['timestamp'], ['desc'])
const groupedAndSortedReactions = Object.values(
groupBy(reactionsWithEmojiData, 'short_name')
).map(groupedReactions =>
orderBy(
groupedReactions,
[reaction => reaction.from.isMe, 'timestamp'],
['desc', 'desc']
)
);
// Order groups by length and subsequently by most recent reaction
const ordered = orderBy(
grouped,
groupedAndSortedReactions,
['length', ([{ timestamp }]) => timestamp],
['desc', 'desc']
);

View File

@ -3,6 +3,7 @@ import * as React from 'react';
import { storiesOf } from '@storybook/react';
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';
@ -32,6 +33,7 @@ storiesOf('Components/Conversation/ReactionPicker', module)
i18n={i18n}
onPick={action('onPick')}
renderEmojiPicker={renderEmojiPicker}
skinTone={0}
/>
);
})
@ -43,6 +45,24 @@ storiesOf('Components/Conversation/ReactionPicker', module)
selected={e}
onPick={action('onPick')}
renderEmojiPicker={renderEmojiPicker}
skinTone={0}
/>
</div>
));
})
.add('Skin Tones', () => {
return ['❤️', '👍', '👎', '😂', '😮', '😢', '😡'].map(e => (
<div key={e} style={{ height: '100px' }}>
<ReactionPicker
i18n={i18n}
selected={e}
onPick={action('onPick')}
renderEmojiPicker={renderEmojiPicker}
skinTone={select(
'skinTone',
{ 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 },
0
)}
/>
</div>
));

View File

@ -17,14 +17,25 @@ export type OwnProps = {
onClose?: () => unknown;
onPick: (emoji: string) => unknown;
renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement;
skinTone: number;
};
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
const emojis = ['❤️', '👍', '👎', '😂', '😮', '😢'];
const DEFAULT_EMOJI_LIST = [
'heart',
'thumbsup',
'thumbsdown',
'joy',
'open_mouth',
'cry',
];
export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
({ i18n, selected, onClose, onPick, renderEmojiPicker, style }, ref) => {
(
{ i18n, selected, onClose, skinTone, onPick, renderEmojiPicker, style },
ref
) => {
const [pickingOther, setPickingOther] = React.useState(false);
const focusRef = React.useRef<HTMLButtonElement>(null);
@ -45,12 +56,16 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
// Handle EmojiPicker::onPickEmoji
const onPickEmoji: EmojiPickerProps['onPickEmoji'] = React.useCallback(
({ shortName, skinTone }) => {
onPick(convertShortName(shortName, skinTone));
({ shortName, skinTone: pickedSkinTone }) => {
onPick(convertShortName(shortName, pickedSkinTone));
},
[onPick]
);
const emojis = DEFAULT_EMOJI_LIST.map(shortName =>
convertShortName(shortName, skinTone)
);
// Focus first button and restore focus on unmount
useRestoreFocus(focusRef);

View File

@ -176,3 +176,42 @@ story.add('Picked Missing Reaction', () => {
});
return <ReactionViewer {...props} />;
});
const skinTones = [
'\u{1F3FB}',
'\u{1F3FC}',
'\u{1F3FD}',
'\u{1F3FE}',
'\u{1F3FF}',
];
const thumbsUpHands = skinTones.map(skinTone => `👍${skinTone}`);
const okHands = skinTones.map(skinTone => `👌${skinTone}`).reverse();
const createReaction = (
emoji: string,
name: string,
timestamp = Date.now()
) => ({
emoji,
from: {
id: '+14155552671',
name,
title: name,
},
timestamp,
});
story.add('Reaction Skin Tones', () => {
const props = createProps({
pickedReaction: '😡',
reactions: [
...thumbsUpHands.map((emoji, n) =>
createReaction(emoji, `Thumbs Up ${n + 1}`, Date.now() + n * 1000)
),
...okHands.map((emoji, n) =>
createReaction(emoji, `Ok Hand ${n + 1}`, Date.now() + n * 1000)
),
],
});
return <ReactionViewer {...props} />;
});

View File

@ -1,11 +1,12 @@
import * as React from 'react';
import { groupBy, mapValues, orderBy, sortBy } from 'lodash';
import { groupBy, mapValues, orderBy } from 'lodash';
import classNames from 'classnames';
import { ContactName } from './ContactName';
import { Avatar, Props as AvatarProps } from '../Avatar';
import { Emoji } from '../emoji/Emoji';
import { useRestoreFocus } from '../../util/hooks';
import { ColorType } from '../../types/Colors';
import { emojiToData, EmojiData } from '../emoji/lib';
export type Reaction = {
emoji: string;
@ -32,14 +33,91 @@ export type Props = OwnProps &
Pick<React.HTMLProps<HTMLDivElement>, 'style'> &
Pick<AvatarProps, 'i18n'>;
const emojisOrder = ['❤️', '👍', '👎', '😂', '😮', '😢', '😡'];
const DEFAULT_EMOJI_ORDER = [
'heart',
'+1',
'-1',
'joy',
'open_mouth',
'cry',
'rage',
];
interface ReactionCategory {
count: number;
emoji?: string;
id: string;
index: number;
}
type ReactionWithEmojiData = Reaction & EmojiData;
export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
({ i18n, reactions, onClose, pickedReaction, ...rest }, ref) => {
const grouped = mapValues(groupBy(reactions, 'emoji'), res =>
orderBy(res, ['timestamp'], ['desc'])
const reactionsWithEmojiData = React.useMemo(
() =>
reactions
.map(reaction => {
const emojiData = emojiToData(reaction.emoji);
if (!emojiData) {
return undefined;
}
return {
...reaction,
...emojiData,
};
})
.filter(
(
reactionWithEmojiData
): reactionWithEmojiData is ReactionWithEmojiData =>
Boolean(reactionWithEmojiData)
),
[reactions]
);
const [selected, setSelected] = React.useState(pickedReaction || 'all');
const groupedAndSortedReactions = React.useMemo(
() =>
mapValues(
{
all: reactionsWithEmojiData,
...groupBy(reactionsWithEmojiData, 'short_name'),
},
groupedReactions => orderBy(groupedReactions, ['timestamp'], ['desc'])
),
[reactionsWithEmojiData]
);
const reactionCategories: Array<ReactionCategory> = React.useMemo(
() =>
[
{
id: 'all',
index: 0,
count: reactionsWithEmojiData.length,
},
...Object.entries(groupedAndSortedReactions)
.filter(([key]) => key !== 'all')
.map(([, [{ short_name: id, emoji }, ...otherReactions]]) => {
return {
id,
index: DEFAULT_EMOJI_ORDER.includes(id)
? DEFAULT_EMOJI_ORDER.indexOf(id)
: Infinity,
emoji,
count: otherReactions.length + 1,
};
}),
].sort((a, b) => a.index - b.index),
[reactionsWithEmojiData, groupedAndSortedReactions]
);
const [
selectedReactionCategory,
setSelectedReactionCategory,
] = React.useState(pickedReaction || 'all');
const focusRef = React.useRef<HTMLButtonElement>(null);
// Handle escape key
@ -60,87 +138,68 @@ export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
// Focus first button and restore focus on unmount
useRestoreFocus(focusRef);
// Create sorted reaction categories, supporting reaction types we don't
// explicitly know about yet
const renderedEmojis = React.useMemo(() => {
const emojiSet = new Set<string>();
reactions.forEach(re => emojiSet.add(re.emoji));
const arr = sortBy(Array.from(emojiSet), emoji => {
const idx = emojisOrder.indexOf(emoji);
if (idx > -1) {
return idx;
}
return Infinity;
});
return ['all', ...arr];
}, [reactions]);
const allSorted = React.useMemo(() => {
return orderBy(reactions, ['timestamp'], ['desc']);
}, [reactions]);
// If we have previously selected a reaction type that is no longer present
// (removed on another device, for instance) we should select another
// reaction type
React.useEffect(() => {
if (!grouped[selected]) {
const toSelect = renderedEmojis[0];
if (toSelect) {
setSelected(toSelect);
if (
!reactionCategories.find(({ id }) => id === selectedReactionCategory)
) {
if (reactionsWithEmojiData.length > 0) {
setSelectedReactionCategory('all');
} else if (onClose) {
// We have nothing to render!
onClose();
}
}
}, [grouped, onClose, renderedEmojis, selected, setSelected]);
}, [
reactionCategories,
onClose,
reactionsWithEmojiData,
selectedReactionCategory,
]);
const selectedReactions = grouped[selected] || allSorted;
const selectedReactions =
groupedAndSortedReactions[selectedReactionCategory] || [];
return (
<div {...rest} ref={ref} className="module-reaction-viewer">
<header className="module-reaction-viewer__header">
{renderedEmojis
.filter(e => e === 'all' || Boolean(grouped[e]))
.map((cat, index) => {
const re = grouped[cat] || reactions;
const maybeFocusRef = index === 0 ? focusRef : undefined;
const isAll = cat === 'all';
{reactionCategories.map(({ id, emoji, count }, index) => {
const isAll = index === 0;
const maybeFocusRef = isAll ? focusRef : undefined;
return (
<button
type="button"
key={cat}
ref={maybeFocusRef}
className={classNames(
'module-reaction-viewer__header__button',
selected === cat
? 'module-reaction-viewer__header__button--selected'
: null
)}
onClick={event => {
event.stopPropagation();
setSelected(cat);
}}
>
{isAll ? (
<span className="module-reaction-viewer__header__button__all">
{i18n('ReactionsViewer--all')}&thinsp;&middot;&thinsp;
{re.length}
return (
<button
type="button"
key={id}
ref={maybeFocusRef}
className={classNames(
'module-reaction-viewer__header__button',
selectedReactionCategory === id
? 'module-reaction-viewer__header__button--selected'
: null
)}
onClick={event => {
event.stopPropagation();
setSelectedReactionCategory(id);
}}
>
{isAll ? (
<span className="module-reaction-viewer__header__button__all">
{i18n('ReactionsViewer--all')}&thinsp;&middot;&thinsp;
{count}
</span>
) : (
<>
<Emoji size={18} emoji={emoji} />
<span className="module-reaction-viewer__header__button__count">
{count}
</span>
) : (
<>
<Emoji size={18} emoji={cat} />
<span className="module-reaction-viewer__header__button__count">
{re.length}
</span>
</>
)}
</button>
);
})}
</>
)}
</button>
);
})}
</header>
<main className="module-reaction-viewer__body">
{selectedReactions.map(({ from, emoji }) => (

View File

@ -3,15 +3,9 @@ import classNames from 'classnames';
import { get, noop } from 'lodash';
import { Manager, Popper, Reference } from 'react-popper';
import { createPortal } from 'react-dom';
import {
EmojiPickDataType,
EmojiPicker,
Props as EmojiPickerProps,
} from './EmojiPicker';
import { EmojiPicker, Props as EmojiPickerProps } from './EmojiPicker';
import { LocalizerType } from '../../types/Util';
export { EmojiPickDataType };
export type OwnProps = {
readonly i18n: LocalizerType;
};

View File

@ -24,7 +24,6 @@ export type EmojiPickDataType = { skinTone?: number; shortName: string };
export type OwnProps = {
readonly i18n: LocalizerType;
readonly disableSkinTones?: boolean;
readonly onPickEmoji: (o: EmojiPickDataType) => unknown;
readonly doSend?: () => unknown;
readonly skinTone?: number;
@ -63,7 +62,6 @@ export const EmojiPicker = React.memo(
doSend,
onPickEmoji,
skinTone = 0,
disableSkinTones = false,
onSetSkinTone,
recentEmojis = [],
style,
@ -79,9 +77,7 @@ export const EmojiPicker = React.memo(
const [searchMode, setSearchMode] = React.useState(false);
const [searchText, setSearchText] = React.useState('');
const [scrollToRow, setScrollToRow] = React.useState(0);
const [selectedTone, setSelectedTone] = React.useState(
disableSkinTones ? 0 : skinTone
);
const [selectedTone, setSelectedTone] = React.useState(skinTone);
const handleToggleSearch = React.useCallback(
(e: React.MouseEvent) => {
@ -383,28 +379,26 @@ export const EmojiPicker = React.memo(
/>
</div>
)}
{!disableSkinTones ? (
<footer className="module-emoji-picker__footer">
{[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>
))}
</footer>
) : null}
<footer className="module-emoji-picker__footer">
{[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>
))}
</footer>
</div>
);
}

View File

@ -263,9 +263,21 @@ export function convertShortName(
}
export function emojiToImage(emoji: string): string | undefined {
if (!Object.prototype.hasOwnProperty.call(imageByEmoji, emoji)) {
return undefined;
}
return imageByEmoji[emoji];
}
export function emojiToData(emoji: string): EmojiData | undefined {
if (!Object.prototype.hasOwnProperty.call(dataByEmoji, emoji)) {
return undefined;
}
return dataByEmoji[emoji];
}
function getCountOfAllMatches(str: string, regex: RegExp) {
let match = regex.exec(str);
let count = 0;

View File

@ -14,11 +14,8 @@ import { LocalizerType } from '../../types/Util';
export const SmartEmojiPicker = React.forwardRef<
HTMLDivElement,
Pick<
EmojiPickerProps,
'onPickEmoji' | 'onClose' | 'style' | 'disableSkinTones'
>
>(({ onPickEmoji, onClose, style, disableSkinTones }, ref) => {
Pick<EmojiPickerProps, 'onPickEmoji' | 'onClose' | 'style'>
>(({ onPickEmoji, onClose, style }, ref) => {
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const skinTone = useSelector<StateType, number>(state =>
get(state, ['items', 'skinTone'], 0)
@ -55,7 +52,6 @@ export const SmartEmojiPicker = React.forwardRef<
recentEmojis={recentEmojis}
onClose={onClose}
style={style}
disableSkinTones={disableSkinTones}
/>
);
});

View File

@ -0,0 +1,29 @@
import * as React from 'react';
import { useSelector } from 'react-redux';
import { get } from 'lodash';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { LocalizerType } from '../../types/Util';
import {
ReactionPicker,
Props,
} from '../../components/conversation/ReactionPicker';
type ExternalProps = Omit<Props, 'skinTone' | 'i18n'>;
export const SmartReactionPicker = React.forwardRef<
HTMLDivElement,
ExternalProps
>((props, ref) => {
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const skinTone = useSelector<StateType, number>(state =>
get(state, ['items', 'skinTone'], 0)
);
return (
<ReactionPicker ref={ref} skinTone={skinTone} i18n={i18n} {...props} />
);
});

View File

@ -63,7 +63,6 @@ function renderEmojiPicker({
onPickEmoji={onPickEmoji}
onClose={onClose}
style={style}
disableSkinTones
/>
);
}

View File

@ -12907,7 +12907,7 @@
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.tsx",
"line": " el.innerHTML = '';",
"lineNumber": 81,
"lineNumber": 78,
"reasonCategory": "usageTrusted",
"updated": "2020-06-03T19:23:21.195Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
@ -13057,7 +13057,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.js",
"line": " this.audioRef = react_1.default.createRef();",
"lineNumber": 59,
"lineNumber": 60,
"reasonCategory": "usageTrusted",
"updated": "2020-08-28T16:12:19.904Z"
},
@ -13065,7 +13065,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.js",
"line": " this.focusRef = react_1.default.createRef();",
"lineNumber": 60,
"lineNumber": 61,
"reasonCategory": "usageTrusted",
"updated": "2020-09-11T17:24:56.124Z",
"reasonDetail": "Used for managing focus only"
@ -13074,7 +13074,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.js",
"line": " this.reactionsContainerRef = react_1.default.createRef();",
"lineNumber": 61,
"lineNumber": 62,
"reasonCategory": "usageTrusted",
"updated": "2020-08-28T16:12:19.904Z",
"reasonDetail": "Used for detecting clicks outside reaction viewer"
@ -13083,23 +13083,23 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();",
"lineNumber": 215,
"reasonCategory": "usageTrusted",
"updated": "2020-09-08T20:19:01.913Z"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
"lineNumber": 217,
"reasonCategory": "usageTrusted",
"updated": "2020-09-08T20:19:01.913Z"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
"lineNumber": 219,
"reasonCategory": "usageTrusted",
"updated": "2020-09-08T20:19:01.913Z"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " > = React.createRef();",
"lineNumber": 221,
"lineNumber": 223,
"reasonCategory": "usageTrusted",
"updated": "2020-08-28T19:36:40.817Z"
},
@ -13344,4 +13344,4 @@
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
}
]
]