From c3ddedfde1e9dafe23eb3c311ea8b695e9f71c65 Mon Sep 17 00:00:00 2001 From: Sidney Keese Date: Fri, 2 Oct 2020 13:05:09 -0700 Subject: [PATCH] Reactions: skin tone support --- ts/components/CompositionArea.tsx | 7 +- ts/components/conversation/Message.tsx | 24 ++- .../conversation/ReactionPicker.stories.tsx | 20 ++ ts/components/conversation/ReactionPicker.tsx | 23 +- .../conversation/ReactionViewer.stories.tsx | 39 ++++ ts/components/conversation/ReactionViewer.tsx | 201 +++++++++++------- ts/components/emoji/EmojiButton.tsx | 8 +- ts/components/emoji/EmojiPicker.tsx | 48 ++--- ts/components/emoji/lib.ts | 12 ++ ts/state/smart/EmojiPicker.tsx | 8 +- ts/state/smart/ReactionPicker.tsx | 29 +++ ts/state/smart/Timeline.tsx | 1 - ts/util/lint/exceptions.json | 28 +-- 13 files changed, 307 insertions(+), 141 deletions(-) create mode 100644 ts/state/smart/ReactionPicker.tsx diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 7497b5143..f853cba7c 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -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; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index fdeed6b10..fd62ce23b 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -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) => void; @@ -1297,8 +1299,7 @@ export class Message extends React.PureComponent { // eslint-disable-next-line consistent-return {({ ref, style }) => ( - { 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'] ); diff --git a/ts/components/conversation/ReactionPicker.stories.tsx b/ts/components/conversation/ReactionPicker.stories.tsx index f16bb8c0b..924651e58 100644 --- a/ts/components/conversation/ReactionPicker.stories.tsx +++ b/ts/components/conversation/ReactionPicker.stories.tsx @@ -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} + /> + + )); + }) + .add('Skin Tones', () => { + return ['❤️', '👍', '👎', '😂', '😮', '😢', '😡'].map(e => ( +
+
)); diff --git a/ts/components/conversation/ReactionPicker.tsx b/ts/components/conversation/ReactionPicker.tsx index 7c8b47bae..f7053ec03 100644 --- a/ts/components/conversation/ReactionPicker.tsx +++ b/ts/components/conversation/ReactionPicker.tsx @@ -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, 'style'>; -const emojis = ['❤️', '👍', '👎', '😂', '😮', '😢']; +const DEFAULT_EMOJI_LIST = [ + 'heart', + 'thumbsup', + 'thumbsdown', + 'joy', + 'open_mouth', + 'cry', +]; export const ReactionPicker = React.forwardRef( - ({ 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(null); @@ -45,12 +56,16 @@ export const ReactionPicker = React.forwardRef( // 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); diff --git a/ts/components/conversation/ReactionViewer.stories.tsx b/ts/components/conversation/ReactionViewer.stories.tsx index 2334588fa..4f30d04fb 100644 --- a/ts/components/conversation/ReactionViewer.stories.tsx +++ b/ts/components/conversation/ReactionViewer.stories.tsx @@ -176,3 +176,42 @@ story.add('Picked Missing Reaction', () => { }); return ; }); + +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 ; +}); diff --git a/ts/components/conversation/ReactionViewer.tsx b/ts/components/conversation/ReactionViewer.tsx index 4633c2aed..070a19f37 100644 --- a/ts/components/conversation/ReactionViewer.tsx +++ b/ts/components/conversation/ReactionViewer.tsx @@ -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, 'style'> & Pick; -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( ({ 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 = 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(null); // Handle escape key @@ -60,87 +138,68 @@ export const ReactionViewer = React.forwardRef( // 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(); - 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 (
- {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 ( - - ); - })} + + )} + + ); + })}
{selectedReactions.map(({ from, emoji }) => ( diff --git a/ts/components/emoji/EmojiButton.tsx b/ts/components/emoji/EmojiButton.tsx index 6fa4b0ce0..00615712a 100644 --- a/ts/components/emoji/EmojiButton.tsx +++ b/ts/components/emoji/EmojiButton.tsx @@ -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; }; diff --git a/ts/components/emoji/EmojiPicker.tsx b/ts/components/emoji/EmojiPicker.tsx index bf82b8344..a28ff0b1d 100644 --- a/ts/components/emoji/EmojiPicker.tsx +++ b/ts/components/emoji/EmojiPicker.tsx @@ -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( />
)} - {!disableSkinTones ? ( -
- {[0, 1, 2, 3, 4, 5].map(tone => ( - - ))} -
- ) : null} +
+ {[0, 1, 2, 3, 4, 5].map(tone => ( + + ))} +
); } diff --git a/ts/components/emoji/lib.ts b/ts/components/emoji/lib.ts index d0df46bc6..591c387a7 100644 --- a/ts/components/emoji/lib.ts +++ b/ts/components/emoji/lib.ts @@ -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; diff --git a/ts/state/smart/EmojiPicker.tsx b/ts/state/smart/EmojiPicker.tsx index f4b6e9ea5..8341bddc2 100644 --- a/ts/state/smart/EmojiPicker.tsx +++ b/ts/state/smart/EmojiPicker.tsx @@ -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 +>(({ onPickEmoji, onClose, style }, ref) => { const i18n = useSelector(getIntl); const skinTone = useSelector(state => get(state, ['items', 'skinTone'], 0) @@ -55,7 +52,6 @@ export const SmartEmojiPicker = React.forwardRef< recentEmojis={recentEmojis} onClose={onClose} style={style} - disableSkinTones={disableSkinTones} /> ); }); diff --git a/ts/state/smart/ReactionPicker.tsx b/ts/state/smart/ReactionPicker.tsx new file mode 100644 index 000000000..2b937a6fc --- /dev/null +++ b/ts/state/smart/ReactionPicker.tsx @@ -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; + +export const SmartReactionPicker = React.forwardRef< + HTMLDivElement, + ExternalProps +>((props, ref) => { + const i18n = useSelector(getIntl); + + const skinTone = useSelector(state => + get(state, ['items', 'skinTone'], 0) + ); + + return ( + + ); +}); diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 071df6241..f6ae95fb6 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -63,7 +63,6 @@ function renderEmojiPicker({ onPickEmoji={onPickEmoji} onClose={onClose} style={style} - disableSkinTones /> ); } diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 9b5e14177..85c4d4762 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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 = 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 = 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 = 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" } -] \ No newline at end of file +]